Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b21011f5a | |||
| 01fa3e4267 | |||
| a1ebb1be21 | |||
| 7de38afec2 | |||
| a5c1931a2a | |||
| 3e80b81e07 | |||
| ef4ee70aac | |||
| 00bb929f22 | |||
| c553735653 | |||
| 680e5f0abd | |||
| 2000278f1a | |||
| 702e6bb973 | |||
| 8a5f7c2bf8 | |||
| 3e4a1f92b1 | |||
| c59f2598b0 | |||
| 30784fa756 | |||
| da0411f5d1 | |||
| 240a366ce8 | |||
| 9c3e1469c7 | |||
| ba7887f6b2 | |||
| 8b1a604dc2 | |||
| be0821b0be | |||
| 913f3c75f1 | |||
| 0169cf04b6 | |||
| ab1d7e68f5 | |||
| 2387d41ebb | |||
| c15c7a0d61 | |||
| bda9391c75 | |||
| 6267e18478 | |||
| 79379b238a | |||
| 75b6995b94 | |||
| 8128ab829f | |||
| 8da3b14e40 | |||
| 55508a27bb | |||
| 892cbdacd2 | |||
| c311e26a77 | |||
| ae10480ed5 | |||
| 0c13b134ab | |||
| 84fe628656 | |||
| f7cf4360c0 | |||
| 9228e335e4 | |||
| e0739cf89a | |||
| 8e48941870 | |||
| f20221fd8d | |||
| 3f579799af | |||
| dae8b21c77 | |||
| 2b93165395 | |||
| dfcb6cc2c1 | |||
| 99fe65fb57 | |||
| dd7d468063 | |||
| 94d77e8843 | |||
| 89b4a297fe | |||
| 1aa413022f | |||
| fd91198315 | |||
| 6df798c403 | |||
| 6d9942d532 |
@@ -0,0 +1,86 @@
|
||||
## Project Intent
|
||||
Vaessl is a **technical playground** and proof-of-concept designed to showcase a complete, professional-grade software lifecycle. The primary goal is not just to build a functional utility, but to demonstrate a multi-experience approach to full-stack engineering, DevOps, and AI integration.
|
||||
|
||||
This project serves as a transparent portfolio of my take on:
|
||||
* **Infrastructure:** Moving from local-machine to browser-based development environment hosted on my Proxmox server.
|
||||
* **Modern AI Orchestration:** Building a system that actually uses AI to do heavy lifting, like identifying objects and understanding natural language using Spring AI and LiteLLM.
|
||||
* **Architectural Foresight:** Designing with abstraction layers early to ensure the system can scale from a single-target tool to a multi-app bridge without significant refactoring.
|
||||
|
||||
---
|
||||
|
||||
## What is Vaessl?
|
||||
Vaessl acts as translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API.
|
||||
|
||||
### Core Functionality
|
||||
* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table.
|
||||
* **AI-Powered Analysis:** The system utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice.
|
||||
* **Semantic Discovery:** By utilizing **Spring AI** and **pgvector**, Vaessl stores description embeddings. This allows for intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe").
|
||||
|
||||
---
|
||||
|
||||
## Technical Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| :--- | :--- |
|
||||
| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI |
|
||||
| **Frontend** | React (Vite), Tailwind CSS, SCSS |
|
||||
| **Database** | PostgreSQL + `pgvector` |
|
||||
| **AI Gateway** | LiteLLM (Unified API proxy) |
|
||||
| **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins |
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure & Methodology
|
||||
|
||||
### 1. Centralized Development (Code-Server)
|
||||
To maintain a zero-footprint local setup, I develop within a custom **code-server** Docker instance.
|
||||
* **Custom Image:** The environment is baked with a specific toolchain (OpenJDK 25, Node.js 24) to ensure absolute environment parity.
|
||||
* **Remote Access:** Secured via Pangolin tunnel, allowing for secure, remote development without exposing ports to the public internet.
|
||||
|
||||
### 2. Abstraction & Scalability
|
||||
A core requirement was to prevent vendor or application lock-in:
|
||||
* **API Abstraction:** While the initial integration targets **Homebox**, the export logic is decoupled. This allows the bridge to support other platforms like **WikiJS** by simply implementing new mapping profiles.
|
||||
* **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes.
|
||||
|
||||
### 3. Testing Strategy
|
||||
* **Database Parity:** I utilize mirrored containers for development and testing (`vaessl-db` vs `vassal-test-db`). This ensures that JPA operations are tested against the real PostgreSQL engine and `pgvector` extensions rather than mocks.
|
||||
* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
* [x] Deployment of core infrastructure (PostgreSQL, LiteLLM, Hoppscotch via Docker).
|
||||
* [x] Custom Code-Server image build and deployment.
|
||||
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
|
||||
|
||||
### Phase 2: The Processing Pipeline (Current)
|
||||
* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow.
|
||||
* [ ] Development of the data refinement UI in React.
|
||||
* [ ] Initial API connector for Homebox.
|
||||
|
||||
### Phase 3: Semantic Search & Expansion
|
||||
* [ ] Integration of Spring AI vector embeddings.
|
||||
* [ ] Implementation of additional API targets (WikiJS).
|
||||
* [ ] Launch of a public-facing demo.
|
||||
|
||||
---
|
||||
|
||||
## Setup & Deployment
|
||||
The project is orchestrated via Docker Compose for high portability.
|
||||
|
||||
1. Clone the Repository.
|
||||
2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys.
|
||||
|
||||
```
|
||||
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
|
||||
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
|
||||
DB_USERNAME={username}
|
||||
DB_PASSWORD={password}
|
||||
OPENAI_KEY={api-key} # or LLM provider of choice
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
|
||||
```
|
||||
3. Setup both production and test pgvector databases using the official pgvector docker image or installing the pgvector extension to you existing PostGreSQL database.
|
||||
4. Execute "npm i" and build gradle.
|
||||
+1
-1
@@ -37,4 +37,4 @@ out/
|
||||
.vscode/
|
||||
|
||||
### Misc ###
|
||||
.env_dev
|
||||
*.local
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
java
|
||||
id("org.springframework.boot") version "4.0.4"
|
||||
id("org.springframework.boot") version "4.0.5"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
}
|
||||
|
||||
@@ -33,16 +33,14 @@ dependencies {
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
// developmentOnly("org.springframework.boot:spring-boot-docker-compose")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
// developmentOnly("org.springframework.ai:spring-ai-spring-boot-docker-compose")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
testImplementation("org.wiremock:wiremock-standalone:3.12.0")}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: 'postgres:latest'
|
||||
environment:
|
||||
- 'POSTGRES_DB=mydatabase'
|
||||
- 'POSTGRES_PASSWORD=secret'
|
||||
- 'POSTGRES_USER=myuser'
|
||||
ports:
|
||||
- '5432'
|
||||
@@ -9,5 +9,4 @@ public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class ConnectionController {
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<ConnectionResponse> loginResponse(@Valid @RequestBody ConnectionRequest request) {
|
||||
return ResponseEntity.ok(connectionService.login(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import jakarta.persistence.DiscriminatorColumn;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Inheritance;
|
||||
import jakarta.persistence.InheritanceType;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
|
||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||
@DiscriminatorColumn(name = "service_type")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public abstract class ConnectionEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String appUrl;
|
||||
private String username;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
|
||||
public interface ConnectionProvider {
|
||||
|
||||
void checkCredentials(ConnectionRequest request);
|
||||
|
||||
String getServiceType();
|
||||
|
||||
ConnectionResponse authenticate(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
|
||||
|
||||
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface ConnectionRepository extends JpaRepository<ConnectionEntity, Long> {
|
||||
|
||||
ConnectionEntity findByAppUrlAndUsername(String appUrl, String username);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
private final Map<String, ConnectionProvider> providerRegistry;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
|
||||
this.providerRegistry = providers.stream()
|
||||
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
public ConnectionResponse login(ConnectionRequest request) {
|
||||
|
||||
ConnectionProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new WrongServiceTypeException();
|
||||
}
|
||||
|
||||
provider.checkCredentials(request);
|
||||
|
||||
ConnectionResponse response = provider.authenticate(request);
|
||||
|
||||
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
|
||||
|
||||
if (existing != null) {
|
||||
provider.updateToRepository(existing, response);
|
||||
} else {
|
||||
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
|
||||
cRepository.save(newEntity);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
public enum Endpoint {
|
||||
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login");
|
||||
|
||||
private final String value;
|
||||
|
||||
private Endpoint(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
|
||||
@Component
|
||||
public class HomeBoxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClientBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkCredentials(ConnectionRequest request) {
|
||||
if (request.username() == null || request.password() == null) {
|
||||
List<String> missingFields = new ArrayList<>();
|
||||
|
||||
if (request.username() == null) {
|
||||
missingFields.add("username");
|
||||
}
|
||||
if (request.password() == null) {
|
||||
missingFields.add("password");
|
||||
|
||||
}
|
||||
throw new EmptyCredentialsException(List.copyOf(missingFields));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getServiceType() {
|
||||
return "HOMEBOX";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||
Map<String, Object> homeboxPayload = Map.of("username", request.username(),
|
||||
"password", request.password(), "stayLoggedIn",
|
||||
request.stayLoggedIn());
|
||||
|
||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
|
||||
.build()
|
||||
.post()
|
||||
.uri(HOMEBOX_LOGIN.getValue())
|
||||
.body(homeboxPayload)
|
||||
.retrieve()
|
||||
.body(HomeboxLoginResponse.class);
|
||||
|
||||
if (hbResponse == null) {
|
||||
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
|
||||
}
|
||||
|
||||
Map<String, Object> attachmentToken = new HashMap<>();
|
||||
|
||||
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
||||
|
||||
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
|
||||
|
||||
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
|
||||
return HomeboxEntity.from(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
||||
|
||||
if (existing instanceof HomeboxEntity hbE) {
|
||||
|
||||
hbE.setToken(response.token());
|
||||
hbE.setExpiresAt(response.expiresAt());
|
||||
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
|
||||
cRepository.save(hbE);
|
||||
}
|
||||
}
|
||||
|
||||
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
|
||||
import jakarta.persistence.DiscriminatorValue;
|
||||
import jakarta.persistence.Entity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@DiscriminatorValue("HOMEBOX")
|
||||
@Getter
|
||||
@Setter
|
||||
public class HomeboxEntity extends ConnectionEntity {
|
||||
|
||||
private String token;
|
||||
private String attachmentToken;
|
||||
private Instant expiresAt;
|
||||
|
||||
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
|
||||
|
||||
HomeboxEntity he = new HomeboxEntity();
|
||||
|
||||
he.setAppUrl(request.appUrl());
|
||||
he.setUsername(request.username());
|
||||
he.setToken(response.token());
|
||||
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
he.setExpiresAt(response.expiresAt());
|
||||
|
||||
return he;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public enum ServiceType {
|
||||
HOMEBOX("HOMEBOX");
|
||||
|
||||
private final String value;
|
||||
|
||||
private ServiceType(String value){
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ConnectionRequest(
|
||||
@NotBlank(message = "App URL is mandatory") String appUrl,
|
||||
@NotBlank(message = "Service type is mandatory") String serviceType,
|
||||
String username,
|
||||
String password,
|
||||
String apiKey,
|
||||
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
||||
|
||||
public ConnectionRequest {
|
||||
if (stayLoggedIn == null) {
|
||||
stayLoggedIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
public ConnectionRequest(String appUrl, String serviceType, String username, String password,
|
||||
Boolean stayLoggedIn) {
|
||||
this(appUrl, serviceType, username, password, null, stayLoggedIn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
|
||||
|
||||
public String getExtraVar(String key) {
|
||||
if(extraResponseData == null) {
|
||||
return null;
|
||||
} else {
|
||||
Object value = extraResponseData.get(key);
|
||||
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class EmptyCredentialsException extends RuntimeException {
|
||||
|
||||
private final List<String> missingFields;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum ErrorMessage {
|
||||
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(
|
||||
HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL(
|
||||
"The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND,
|
||||
"No such service type.");
|
||||
|
||||
private final HttpStatus status;
|
||||
private final String message;
|
||||
|
||||
private ErrorMessage(HttpStatus status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
private ErrorMessage(String message) {
|
||||
this.status = null;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import static com.vaessl.app.exception.ErrorMessage.*;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
||||
|
||||
String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpClientErrorException.Unauthorized.class)
|
||||
public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) {
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(),
|
||||
UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResourceAccessException.class)
|
||||
public ProblemDetail handleNoConnection(ResourceAccessException e) {
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(),
|
||||
SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpServerErrorException.class)
|
||||
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
||||
|
||||
return ProblemDetail
|
||||
.forStatusAndDetail(e.getStatusCode(),
|
||||
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
||||
}
|
||||
|
||||
@ExceptionHandler(WrongServiceTypeException.class)
|
||||
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
|
||||
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmptyCredentialsException.class)
|
||||
public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) {
|
||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
public class WrongServiceTypeException extends RuntimeException {
|
||||
}
|
||||
@@ -2,18 +2,19 @@ spring:
|
||||
application:
|
||||
name: vaessl
|
||||
config:
|
||||
import: "optional:file:.env_dev[.properties]"
|
||||
import:
|
||||
- "optional:file:.env.local[.properties]"
|
||||
- "optional:file:backend/.env.local[.properties]"
|
||||
- "optional:file:vaessl/backend/.env.local[.properties]"
|
||||
datasource:
|
||||
url : ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
|
||||
ai:
|
||||
openai:
|
||||
base-url: ${OPENAI_BASE_URL}
|
||||
@@ -21,7 +22,6 @@ spring:
|
||||
chat:
|
||||
options:
|
||||
model: gpt-4o-mini
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.boot.context.config: TRACE
|
||||
server:
|
||||
servlet:
|
||||
context-path: /api
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
package com.vaessl.app;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class ApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionToTestDbWorks() throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
assertThat(connection.getMetaData().getURL()).contains("vaessl_test");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||
|
||||
import static com.vaessl.app.connection.Mockdata.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConnectionServiceTest {
|
||||
|
||||
@Mock
|
||||
private ConnectionProvider mockProvider;
|
||||
@Mock
|
||||
private ConnectionRepository mockRepo;
|
||||
|
||||
private ConnectionService connectionService;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE);
|
||||
connectionService = new ConnectionService(List.of(mockProvider), mockRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
||||
|
||||
doThrow(new EmptyCredentialsException(List.of("username")))
|
||||
.when(mockProvider).checkCredentials(request);
|
||||
|
||||
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
||||
|
||||
verify(mockProvider, never()).authenticate(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static com.vaessl.app.connection.Mockdata.*;
|
||||
|
||||
class HomeBoxConnectionProviderTest {
|
||||
|
||||
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null);
|
||||
|
||||
@Test
|
||||
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
|
||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false);
|
||||
|
||||
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
|
||||
provider.checkCredentials(request);
|
||||
});
|
||||
|
||||
assertThat(exception.getMissingFields()).containsExactly("username", "password");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
|
||||
import com.jayway.jsonpath.DocumentContext;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
import static com.vaessl.app.exception.ErrorMessage.*;
|
||||
import static com.vaessl.app.connection.ServiceType.*;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@AutoConfigureTestRestTemplate
|
||||
@WireMockTest
|
||||
class HomeboxIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
ConnectionRepository cRepository;
|
||||
|
||||
String okJsonHomeboxResponse = """
|
||||
{
|
||||
"token": "fake-jwt-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2026-04-26T02:23:13Z"
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String MOCK_URL = "http://localhost:1234";
|
||||
private static final String TEST_USER = "admin";
|
||||
private static final String TEST_PASS = "pw";
|
||||
|
||||
/**
|
||||
* Returns Token and status code OK when login is successful.
|
||||
*
|
||||
* @param wm
|
||||
* the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
||||
String.class);
|
||||
|
||||
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
||||
|
||||
String token = documentContext.read("$.token");
|
||||
assertThat(token).isEqualTo("fake-jwt-token");
|
||||
|
||||
String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken");
|
||||
assertThat(attachmentToken).isEqualTo("fake-attach");
|
||||
|
||||
String expiresAt = documentContext.read("$.expiresAt", String.class);
|
||||
assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the Unauthorized custom exception.
|
||||
*
|
||||
* @param wm
|
||||
* the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a server error from the external api.
|
||||
*
|
||||
* @param wm
|
||||
* the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when the service is unavailable or the app URL is wrong.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() {
|
||||
|
||||
ConnectionRequest badRequest = new ConnectionRequest(
|
||||
MOCK_URL,
|
||||
HOMEBOX.getValue(),
|
||||
TEST_USER, TEST_PASS,
|
||||
false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any login fields are empty since all of them are mandatory.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
|
||||
|
||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the exception when there is an input for serviceType but it's
|
||||
* unsupported.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnWrongServiceTypeException() {
|
||||
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
|
||||
MOCK_URL,
|
||||
"wrong-service-type",
|
||||
TEST_USER, TEST_PASS,
|
||||
false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq,
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the succesfull persistance of Homebox credential response to the
|
||||
* database.
|
||||
*
|
||||
* @param wm
|
||||
* the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue()))
|
||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
||||
|
||||
ConnectionRequest request = connectionRequest(wm);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||
|
||||
DocumentContext responseContext = JsonPath.parse(response.getBody());
|
||||
|
||||
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
|
||||
assertThat(dbEntry).isNotNull();
|
||||
|
||||
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
||||
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
|
||||
|
||||
if (dbEntry instanceof HomeboxEntity hbE) {
|
||||
assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token"));
|
||||
assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken"));
|
||||
assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() {
|
||||
ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_USER, null,
|
||||
false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials,
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid connection request with a mock Api throuh
|
||||
* WireMockRuntimeInfo.
|
||||
*
|
||||
* @param wm
|
||||
* the WiremockRuntimeInfo object
|
||||
* @return a mock api connection request.
|
||||
*/
|
||||
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
|
||||
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS,
|
||||
null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
public final class Mockdata {
|
||||
|
||||
private Mockdata() {}
|
||||
|
||||
public static final String MOCK_URL = "http://localhost:1234";
|
||||
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url : ${DB_TEST_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
@@ -25,14 +25,14 @@ services:
|
||||
# - HASHED_PASSWORD= #optional
|
||||
# - SUDO_PASSWORD=password #optional
|
||||
# - SUDO_PASSWORD_HASH= #optional
|
||||
- PROXY_DOMAIN=code-server.my.domain #this is important to generate a proper forward address for ports 8080 and 3000
|
||||
- PROXY_DOMAIN=code-server.my.domain #this is important to generate a proper forward address for ports 8080 and 5173
|
||||
- DEFAULT_WORKSPACE=/config/workspace #optional
|
||||
volumes:
|
||||
- /home/pi/docker/vscode:/config
|
||||
ports:
|
||||
- 8443:8443
|
||||
- 8124:8080 #spring port
|
||||
- 3124:3000 #react port
|
||||
- 8124:8080 # spring port
|
||||
- 5173:5173 # vite port
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Vaessl is an AI-powered bridge designed to connect physical reality to digital m
|
||||
# Technical requirements & stack
|
||||
|
||||
## Frontend
|
||||
- Framework: Next.js (React)
|
||||
- Framework: Vite (React)
|
||||
|
||||
- Styling: Tailwind CSS + SCSS
|
||||
|
||||
@@ -52,7 +52,7 @@ The image processing is the heart of Vaessl's "bridge" functionality. It ensures
|
||||
## Deployment
|
||||
Vaessl is deployed via Docker Compose for high portability.
|
||||
|
||||
- Container 1: Next.js (Web UI)
|
||||
- Container 1: Vite (Web UI)
|
||||
- Container 2: Spring Boot
|
||||
- Container 3: PostgreSQL + pgvector
|
||||
- Container 4: LiteLLM
|
||||
|
||||
@@ -29,12 +29,12 @@ Vaessl is intended to be a useful tool for the public, specifically those utiliz
|
||||
# Short-Term Roadmap & Milestones
|
||||
|
||||
- Phase 1: Foundation (Current)
|
||||
* Deploy the core infrastructure (PostgreSQL + pgvector, LiteLLM, Spring Boot Skeleton, Next.Js frontend).
|
||||
* Deploy the core infrastructure (PostgreSQL + pgvector, LiteLLM, Spring Boot Skeleton, Vite frontend).
|
||||
* Establish the bridge between the Vaessl backend and a demo Homebox instance.
|
||||
|
||||
- Phase 2: The Processing Pipeline
|
||||
* Implement the image processing workflow: Upload $\rightarrow$ AI analysis $\rightarrow$ staging Table.
|
||||
* Develop the Next.js interface for data refinement and verification.
|
||||
* Develop the Vite interface for data refinement and verification.
|
||||
|
||||
- Phase 3: Semantic Search & Demo
|
||||
* Integrate Spring AI for embedding generation.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
**Vaessl: code-server adjustments**
|
||||
|
||||
Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Next.js. I install openjdk, npm, nodejs and yarn and set the environment variables for Java. Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here:
|
||||
Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Vite.
|
||||
|
||||
I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java.
|
||||
|
||||
Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here:
|
||||
|
||||
```
|
||||
FROM lscr.io/linuxserver/code-server:latest
|
||||
@@ -8,11 +12,13 @@ FROM lscr.io/linuxserver/code-server:latest
|
||||
USER root
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
openjdk-25-jdk maven \
|
||||
nodejs npm \
|
||||
&& npm install -g yarn \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
curl ca-certificates openjdk-25-jdk \
|
||||
# Add nodejs for React/Vite
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
||||
&& apt install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Java Environment
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||
ENV PATH="$JAVA_HOME/bin:${PATH}"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Vaessl: Spring Boot setup
|
||||
**Vaessl: Spring Boot setup**
|
||||
|
||||
This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS.
|
||||
|
||||
The dependencies are chosen to specifically work with Spring AI, PostgreSQL and Docker. For the AI function OpenAI is chosen since it is known to work with LiteLLM. The PostgreSQL dependency makes sure to include PostGreSQL support for self hosted databases. Docker Compose makes sure to handle future Docker implementations.
|
||||
The dependencies are chosen to specifically work with Spring AI and PostgreSQL/pgvector. For the AI function OpenAI is chosen since it is known to work with LiteLLM. The PostgreSQL dependency makes sure to include PostGreSQL support for self hosted databases.
|
||||
|
||||
# Spring Initializr settings
|
||||
|
||||
@@ -22,7 +22,6 @@ Gradel Kotlin is chosen to make mobile/Android app development easier.
|
||||
|
||||
- Lombok
|
||||
- Spring Boot DevTools
|
||||
- Docker Compose Support
|
||||
- Spring Web
|
||||
- Spring Security
|
||||
- Spring Data JPA
|
||||
@@ -32,7 +31,7 @@ Gradel Kotlin is chosen to make mobile/Android app development easier.
|
||||
|
||||
# Project Settings
|
||||
|
||||
Docker Compose, PostGreSQL and OpenAI need an initial setup so that the local instance is able to start. Since I will handle Docker issues later I will comment the dependencies out for now. I will also comment out Spring Security since user management is an issue for a later iteration of the app.
|
||||
PostGreSQL and OpenAI need an initial setup so that the local instance is able to start. I will comment out Spring Security since user management is an issue for a later iteration of the app.
|
||||
|
||||
The build.gradle.kts will look something like this:
|
||||
|
||||
@@ -72,15 +71,14 @@ dependencies {
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
// developmentOnly("org.springframework.boot:spring-boot-docker-compose")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
// developmentOnly("org.springframework.ai:spring-ai-spring-boot-docker-compose")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
@@ -95,10 +93,11 @@ tasks.withType<Test> {
|
||||
|
||||
```
|
||||
|
||||
To configure OpenAI (which I will use instead of LiteLLM initially since I have an OpenAI Api key and can get going quickly) and PostGreSQL I will create a .env_dev file in the root dir, fill all credentials and add it to .gitignore
|
||||
To configure OpenAI (which I will use instead of LiteLLM initially since I have an OpenAI Api key and can get going quickly) and PostGreSQL I will create a .env.local file in the root dir, fill all credentials and add it to .gitignore
|
||||
|
||||
```
|
||||
DB_URL=jdbc:postgresql://192.168.1.208:5432/vaessl-playground
|
||||
DB_URL=jdbc:postgresql://192.168.1.208:5433/vaessl
|
||||
DB_TEST_URL=jdbc:postgresql://192.168.1.208:5434/vaessl_test
|
||||
DB_USERNAME=myusername
|
||||
DB_PASSWORD=mypw
|
||||
OPENAI_KEY=myapikey
|
||||
@@ -113,7 +112,7 @@ spring:
|
||||
application:
|
||||
name: vaessl
|
||||
config:
|
||||
import: "optional:file:.env_dev[.properties]"
|
||||
import: "optional:file:.env.local[.properties]"
|
||||
datasource:
|
||||
url : ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
@@ -139,7 +138,52 @@ spring:
|
||||
|
||||
```
|
||||
|
||||
Note that I'm using my own locally hosted PostgreSQL instance here and created a dedicated db for this project. There is a Docker Compose config with the Spring Boot dependency for this but since my DB is always on and running independently I don't need the Docker Compose management.
|
||||
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. The Docker Compose file will look something like this:
|
||||
|
||||
```
|
||||
---
|
||||
services:
|
||||
code-server:
|
||||
image: code-server-dev:latest
|
||||
container_name: code-server
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/Vienna
|
||||
# - PASSWORD= #optional
|
||||
- HASHED_PASSWORD=hashedpw
|
||||
# - SUDO_PASSWORD_HASH= #optional
|
||||
- PROXY_DOMAIN=code-server.your.website
|
||||
- DEFAULT_WORKSPACE=/config/workspace
|
||||
volumes:
|
||||
- /home/pi/docker/vscode:/config
|
||||
- /home/pi/docker/vscode/workspace:/workspace
|
||||
ports:
|
||||
- 8443:8443
|
||||
- 8124:8080
|
||||
- 5173:5173
|
||||
restart: unless-stopped
|
||||
|
||||
vaessl-db:
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: vaessl-db
|
||||
environment:
|
||||
- POSTGRES_DB=vaessl
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pw
|
||||
ports:
|
||||
- 5433:5432
|
||||
|
||||
vassal-test-db:
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: vassal-test-db
|
||||
environment:
|
||||
- POSTGRES_DB=vassal_test
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pw
|
||||
ports:
|
||||
- 5434:5432
|
||||
```
|
||||
|
||||
# Appendix: Additional config for developing in Code-Server
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
**Vaessl: Frontend setup**
|
||||
|
||||
Vite will act as development server for the React app. The necessary packages especially the correct NodeJs version are baked into the Docker image documented in [code-server adjustments](https://gitea.kasuns.website/kasun/Vaessl/src/branch/main/docs/02-Preparation/01-code-server-adjustments.md)
|
||||
|
||||
Port 5173 needs to be exposed in the code-server container via port variable.
|
||||
|
||||
To ensure proper localhost Url generation the following server parameters need to be added to the vite.config.ts.
|
||||
|
||||
```
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
allowedHosts: ['5173.code-server.your.website'],
|
||||
},
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
Don't forget to add the host to your proxy service.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
**vaessl: Test strategy**
|
||||
|
||||
# Spring Boot Test Environment
|
||||
1. Environment Parity
|
||||
|
||||
Instead of testing against a mocked database or the development database, I created a mirrored test container. This allows the application to perform JPA operations against a real PostgreSQL instance that matches the production engine but uses a different port (5434) and database name (vaessl_test).
|
||||
|
||||
2. Profile-Driven Configuration
|
||||
|
||||
I utilized the Spring Profiles mechanism. By tagging the test class with @ActiveProfiles("test"), Spring Boot ignores the standard application.yaml for specific keys and prioritizes src/test/resources/application-test.yaml.
|
||||
|
||||
The configuration is moved out of the Java code and into YAML. This follows the "Separation of Concerns" principle.
|
||||
|
||||
The file is placed in src/test/resources (a sibling to src/test/java). This is the standard Gradle "SourceSet" layout, ensuring the build tool automatically packages these settings only during the test phase.
|
||||
|
||||
3. Gradle Test Lifecycle
|
||||
|
||||
I corrected the dependency graph in build.gradle.kts. While the Initializr provides "test slices" (like data-jpa-test), a full integration test requires the Spring Boot Starter Test core.
|
||||
|
||||
This starter provides the YAML parsing engine and the SpringBootContextLoader.
|
||||
|
||||
# Frontend Test environment
|
||||
|
||||
1. The Vitest Stack
|
||||
|
||||
- Vitest: providing a unified configuration for both the development server and the test runner.
|
||||
|
||||
- jsdom: Provides a lightweight browser environment in Node.js.
|
||||
|
||||
- React Testing Library: Ensures tests are written from the user's perspective (DOM-based) rather than implementation details.
|
||||
|
||||
2. Dynamic Environment Configuration
|
||||
|
||||
The vitest.config.ts is configured to be environment-aware. By using loadEnv, the test runner can access variables from .env files, allowing us to:
|
||||
|
||||
- Dynamically set the Server Port (51204).
|
||||
|
||||
- Handle Allowed Hosts (crucial for code-server or LXC environments where the public URL might change).
|
||||
|
||||
- Inject environment-specific keys into the test.env context.
|
||||
|
||||
3. TDD Workflow (Red-Green-Refactor)
|
||||
|
||||
The environment is optimized for a fast feedback loop.
|
||||
|
||||
- npm run test: Runs Vitest in "Watch Mode," re-triggering tests instantly upon file changes.
|
||||
|
||||
- npm run test:ui: Launches the Vitest UI, providing a visual graph of test suites and real-time failure reports.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
**vaessl: Setup LiteLLM**
|
||||
|
||||
# LiteLLM Docker Compose setup
|
||||
|
||||
I chose to use my existing Docker and PostgreSQL instance to spin up a LiteLLM container. I use Portainer stacks to write the docker-compose.yaml. Instead of creating a .env file I just wrote all environment variables into the yaml. The official docker-compose.yaml may vary.
|
||||
|
||||
Before deploying the container create a config.yaml and prometheus.yml in the root folder:
|
||||
|
||||
*config.yaml*
|
||||
```
|
||||
general_settings:
|
||||
master_key: sk-key # 🔑 your proxy admin key (must start with sk-)
|
||||
database_url: "postgresql://pgUser:pgUserPw@192.168.1.208:5432/litellm"
|
||||
```
|
||||
|
||||
*prometheus.yml*
|
||||
|
||||
```
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "litellm"
|
||||
static_configs:
|
||||
- targets: ["litellm:4000"]
|
||||
```
|
||||
|
||||
*Portainer stack*
|
||||
```
|
||||
services:
|
||||
litellm:
|
||||
#build:
|
||||
# context: .
|
||||
# args:
|
||||
# target: runtime
|
||||
image: docker.litellm.ai/berriai/litellm:main-stable
|
||||
volumes:
|
||||
- /home/pi/docker/litellm/config.yaml:/app/config.yaml
|
||||
command:
|
||||
- "--config=/app/config.yaml"
|
||||
ports:
|
||||
- "4000:4000" # Map the container port to the host, change the host port if necessary
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://pgUser:pgUserPw@192.168.1.208:5432/litellm"
|
||||
STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI
|
||||
OPENAI_API_KEY: "sk-key"
|
||||
OPENAI_BASE_URL: "https://api.openai.com/v1"
|
||||
LITELLM_MASTER_KEY: "sk-key"
|
||||
LITELLM_SALT_KEY: "sk-saltkey"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
#env_file:
|
||||
# - /home/pi/docker/litellm/.env
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
|
||||
interval: 30s # Perform health check every 30 seconds
|
||||
timeout: 10s # Health check command times out after 10 seconds
|
||||
retries: 3 # Retry up to 3 times if health check fails
|
||||
start_period: 40s # Wait 40 seconds after container start before beginning health checks
|
||||
networks:
|
||||
- postgres_pg_network
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
volumes:
|
||||
- /home/pi/docker/litellm/prometheus_data:/prometheus
|
||||
- /home/pi/docker/litellm/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
- "9090:9090"
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
driver: local
|
||||
postgres_data:
|
||||
name: litellm_postgres_data # Named volume for Postgres data persistence
|
||||
|
||||
networks:
|
||||
postgres_pg_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
|
||||
|
||||
Models can be added in the UI under localhost:4000 and will be saved into the database. No need to define them in the config.yaml
|
||||
@@ -0,0 +1,57 @@
|
||||
**vaessl: Setup API client**
|
||||
|
||||
# Locally hosted API client
|
||||
|
||||
I chose [Hoppscotch ](https://docs.hoppscotch.io/) as API client since it is free, open-source, has a Docker support and can be locally hosted. Together with its browser extension it can be run in the browser supporting my goal of a flexible, browser-based development environment where I can securely access and interact with my entire stack remotely.
|
||||
|
||||
```
|
||||
services:
|
||||
hoppscotch:
|
||||
image: hoppscotch/hoppscotch
|
||||
container_name: hoppscotch
|
||||
restart: unless-stopped
|
||||
# ATTENTION: Run command and entrypoint for the first time and then comment it out
|
||||
#command: '-c "pnpx prisma migrate deploy"'
|
||||
#entrypoint: sh
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
# Prisma Config
|
||||
DATABASE_URL: postgresql://user:pw@192.168.1.208:5432/hoppscotch
|
||||
# (Optional) By default, the AIO container (when in subpath access mode) exposes the endpoint on port 80. Use this setting to specify a different port if needed.
|
||||
HOPP_AIO_ALTERNATE_PORT: 80
|
||||
# Sensitive Data Encryption Key while storing in Database (32 character)
|
||||
DATA_ENCRYPTION_KEY: 'key'
|
||||
# Whitelisted origins for the Hoppscotch App.
|
||||
# This list controls which origins can interact with the app through cross-origin comms.
|
||||
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services
|
||||
# - app://localhost_3200: Bundle server origin identifier
|
||||
# NOTE: `3200` here refers to the bundle server (port 3200) that provides the bundles,
|
||||
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
|
||||
# bundle names like `app://{bundle-name}/`
|
||||
WHITELISTED_ORIGINS: http://localhost:3170,http://localhost:3009,http://localhost:3101,app://localhost_3200,app://hoppscotch
|
||||
# Base URLs
|
||||
VITE_BASE_URL: http://localhost:3009
|
||||
VITE_SHORTCODE_BASE_URL: http://localhost:3009
|
||||
VITE_ADMIN_URL: http://localhost:3101
|
||||
# Backend URLs
|
||||
VITE_BACKEND_GQL_URL: http://localhost:3170/graphql
|
||||
VITE_BACKEND_WS_URL: wss://localhost:3170/graphql
|
||||
VITE_BACKEND_API_URL: http://localhost:3170/v1
|
||||
# Terms Of Service And Privacy Policy Links (Optional)
|
||||
VITE_APP_TOS_LINK: https://docs.hoppscotch.io/support/terms
|
||||
VITE_APP_PRIVACY_POLICY_LINK: https://docs.hoppscotch.io/support/privacy
|
||||
# Set to `true` for subpath based access
|
||||
ENABLE_SUBPATH_BASED_ACCESS: false
|
||||
ports:
|
||||
- '3170:3170'
|
||||
- '3101:3100'
|
||||
- '3009:3000'
|
||||
networks:
|
||||
- postgres_pg_network
|
||||
|
||||
networks:
|
||||
postgres_pg_network:
|
||||
external: true
|
||||
|
||||
```
|
||||
@@ -0,0 +1,302 @@
|
||||
**vaessl: Login Architecture**
|
||||
|
||||
# Backend
|
||||
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods.
|
||||
|
||||
## Single Table Inheritance
|
||||
The database entities will follow the Single Table Inheritance concept.
|
||||
|
||||
The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field.
|
||||
The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy:
|
||||
|
||||
***ConnectionEntitiy.java***
|
||||
|
||||
```
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import jakarta.persistence.DiscriminatorColumn;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Inheritance;
|
||||
import jakarta.persistence.InheritanceType;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "connections")
|
||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||
@DiscriminatorColumn(name = "service_type")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public abstract class ConnectionEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
Long id;
|
||||
|
||||
private String appUrl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
***HomeboxEntitiy.java***
|
||||
|
||||
```
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
|
||||
import jakarta.persistence.DiscriminatorValue;
|
||||
import jakarta.persistence.Entity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@DiscriminatorValue("HOMEBOX")
|
||||
@Getter
|
||||
@Setter
|
||||
public class HomeboxEntity extends ConnectionEntity {
|
||||
|
||||
private String token;
|
||||
private String attachmentToken;
|
||||
private Instant expiresAt;
|
||||
|
||||
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
|
||||
|
||||
HomeboxEntity he = new HomeboxEntity();
|
||||
|
||||
he.setAppUrl(request.appUrl());
|
||||
he.setToken(response.token());
|
||||
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
he.setExpiresAt(response.expiresAt());
|
||||
|
||||
return he;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## The Provider Pattern (Logic Layer)
|
||||
To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General).
|
||||
|
||||
- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity.
|
||||
|
||||
***ConnectionProvider.java***
|
||||
```
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
|
||||
public interface ConnectionProvider {
|
||||
|
||||
String getServiceType();
|
||||
|
||||
ConnectionResponse authenticate(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
|
||||
|
||||
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
|
||||
}
|
||||
|
||||
```
|
||||
***HomeboxConnectionProvider.java***
|
||||
```
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
|
||||
@Component
|
||||
public class HomeBoxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClientBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getServiceType() {
|
||||
return "HOMEBOX";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"),
|
||||
"password", request.credentials().get("password"), "stayLoggedIn",
|
||||
request.stayLoggedIn());
|
||||
|
||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
|
||||
.build()
|
||||
.post()
|
||||
.uri(HOMEBOX_LOGIN.getValue())
|
||||
.body(homeboxPayload)
|
||||
.retrieve()
|
||||
.body(HomeboxLoginResponse.class);
|
||||
|
||||
if (hbResponse == null) {
|
||||
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
|
||||
}
|
||||
|
||||
Map<String, Object> attachmentToken = new HashMap<>();
|
||||
|
||||
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
||||
|
||||
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
|
||||
return HomeboxEntity.from(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
||||
|
||||
|
||||
if (existing instanceof HomeboxEntity hbE) {
|
||||
|
||||
hbE.setToken(response.token());
|
||||
hbE.setExpiresAt(response.expiresAt());
|
||||
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
|
||||
cRepository.save(hbE);
|
||||
}
|
||||
}
|
||||
|
||||
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string.
|
||||
|
||||
***ConnectionService.java***
|
||||
```
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionResponse;
|
||||
import com.vaessl.app.exception.ProviderNotFoundException;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
private final Map<String, ConnectionProvider> providerRegistry;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
|
||||
this.providerRegistry = providers.stream()
|
||||
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
public ConnectionResponse login(ConnectionRequest request) {
|
||||
|
||||
ConnectionProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new ProviderNotFoundException();
|
||||
}
|
||||
|
||||
ConnectionResponse response = provider.authenticate(request);
|
||||
|
||||
ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl());
|
||||
|
||||
if (existing != null) {
|
||||
provider.updateToRepository(existing, response);
|
||||
} else {
|
||||
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
|
||||
cRepository.save(newEntity);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Data Exchange (The DTOs)
|
||||
Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database.
|
||||
|
||||
- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map<String, String> for credentials. This allows one DTO to handle both username/password logins and API-key-only logins.
|
||||
|
||||
***ConnectionRequest.java***
|
||||
|
||||
```
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
|
||||
public record ConnectionRequest(
|
||||
@NotBlank(message = "App URL is mandatory") String appUrl,
|
||||
@NotBlank(message = "Service type is mandatory") String serviceType,
|
||||
@NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials,
|
||||
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
||||
|
||||
public ConnectionRequest {
|
||||
if (stayLoggedIn == null) {
|
||||
stayLoggedIn = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map<String, Object> called extraResponseData.
|
||||
|
||||
- The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need.
|
||||
|
||||
***ConnectionResponse.java***
|
||||
|
||||
```
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
|
||||
|
||||
public String getExtraVar(String key) {
|
||||
if(extraResponseData == null) {
|
||||
return null;
|
||||
} else {
|
||||
Object value = extraResponseData.get(key);
|
||||
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vite
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
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'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,111 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { it, expect, describe } from 'vitest'
|
||||
|
||||
describe('group', () => {
|
||||
it('should', () => {
|
||||
expect(1).toBeTruthy();
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
allowedHosts: ['5173.code-server.kasuns.website'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
server: {
|
||||
port: 51204,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: env.VITEST_PUBLIC_URL ? [env.VITEST_PUBLIC_URL] : [],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
env: loadEnv(mode, process.cwd(), '')
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user