mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat: add system information endpoint and integrate S3 support
- Implemented a new endpoint to retrieve system information, including the active storage provider and S3 status. - Updated the AppService to fetch system information and return relevant data. - Integrated system information fetching in the FileUploadSection, GlobalDropZone, and UploadFileModal components to adjust upload behavior based on S3 availability. - Enhanced chunked upload logic to conditionally use chunked uploads based on the storage provider.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,6 +30,7 @@ apps/server/dist/*
|
||||
|
||||
#DEFAULT
|
||||
.env
|
||||
.steering
|
||||
data/
|
||||
|
||||
node_modules/
|
@@ -18,6 +18,15 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemInfo(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const systemInfo = await this.appService.getSystemInfo();
|
||||
return reply.send(systemInfo);
|
||||
} catch (error: any) {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const configs = await this.appService.getAllConfigs();
|
||||
|
@@ -53,6 +53,26 @@ export async function appRoutes(app: FastifyInstance) {
|
||||
appController.getAppInfo.bind(appController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/app/system-info",
|
||||
{
|
||||
schema: {
|
||||
tags: ["App"],
|
||||
operationId: "getSystemInfo",
|
||||
summary: "Get system information",
|
||||
description: "Get system information including storage provider",
|
||||
response: {
|
||||
200: z.object({
|
||||
storageProvider: z.enum(["s3", "filesystem"]).describe("The active storage provider"),
|
||||
s3Enabled: z.boolean().describe("Whether S3 storage is enabled"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
appController.getSystemInfo.bind(appController)
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/app/configs/:key",
|
||||
{
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { isS3Enabled } from "../../config/storage.config";
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { ConfigService } from "../config/service";
|
||||
|
||||
@@ -20,6 +21,13 @@ export class AppService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return {
|
||||
storageProvider: isS3Enabled ? "s3" : "filesystem",
|
||||
s3Enabled: isS3Enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllConfigs() {
|
||||
return prisma.appConfig.findMany({
|
||||
where: {
|
||||
|
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
|
||||
@@ -25,9 +26,24 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
const [uploaderEmail, setUploaderEmail] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
const validateFileSize = useCallback(
|
||||
(file: File): string | null => {
|
||||
if (!reverseShare.maxFileSize) return null;
|
||||
@@ -139,7 +155,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
presignedUrl: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> => {
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -148,6 +164,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
file,
|
||||
url: presignedUrl,
|
||||
chunkSize,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
|
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/app/system-info`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
@@ -43,6 +44,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
|
||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
|
||||
const generateFileId = useCallback(() => {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
@@ -124,7 +126,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -134,6 +136,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
url,
|
||||
chunkSize,
|
||||
signal: abortController.signal,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress: (progress) => {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||
},
|
||||
@@ -256,6 +259,20 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
[uploadFile, t, createFileUpload]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
document.addEventListener("dragleave", handleDragLeave);
|
||||
|
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
@@ -87,8 +88,23 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fileUploads.forEach((upload) => {
|
||||
@@ -252,7 +268,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -262,6 +278,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
url,
|
||||
chunkSize,
|
||||
signal: abortController.signal,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress: (progress) => {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||
},
|
||||
|
@@ -7,6 +7,7 @@ import type {
|
||||
CheckUploadAllowedResult,
|
||||
GetAppInfoResult,
|
||||
GetDiskSpaceResult,
|
||||
GetSystemInfoResult,
|
||||
RemoveLogoResult,
|
||||
UploadLogoBody,
|
||||
UploadLogoResult,
|
||||
@@ -20,6 +21,14 @@ export const getAppInfo = <TData = GetAppInfoResult>(options?: AxiosRequestConfi
|
||||
return apiInstance.get(`/api/app/info`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system information including storage provider
|
||||
* @summary Get system information
|
||||
*/
|
||||
export const getSystemInfo = <TData = GetSystemInfoResult>(options?: AxiosRequestConfig): Promise<TData> => {
|
||||
return apiInstance.get(`/api/app/system-info`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a new app logo (admin only)
|
||||
* @summary Upload app logo
|
||||
|
@@ -32,6 +32,11 @@ export interface GetAppInfo200 {
|
||||
firstUserAccess: boolean;
|
||||
}
|
||||
|
||||
export interface GetSystemInfo200 {
|
||||
storageProvider: "s3" | "filesystem";
|
||||
s3Enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveLogo200 {
|
||||
message: string;
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export interface UploadLogoBody {
|
||||
}
|
||||
|
||||
export type GetAppInfoResult = AxiosResponse<GetAppInfo200>;
|
||||
export type GetSystemInfoResult = AxiosResponse<GetSystemInfo200>;
|
||||
export type UploadLogoResult = AxiosResponse<UploadLogo200>;
|
||||
export type RemoveLogoResult = AxiosResponse<RemoveLogo200>;
|
||||
export type CheckHealthResult = AxiosResponse<CheckHealth200>;
|
||||
|
@@ -7,6 +7,7 @@ export interface ChunkedUploadOptions {
|
||||
onProgress?: (progress: number) => void;
|
||||
onChunkComplete?: (chunkIndex: number, totalChunks: number) => void;
|
||||
signal?: AbortSignal;
|
||||
isS3Enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ChunkedUploadResult {
|
||||
@@ -23,7 +24,7 @@ export class ChunkedUploader {
|
||||
static async uploadFile(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
|
||||
const { file, url, chunkSize, onProgress, onChunkComplete, signal } = options;
|
||||
|
||||
if (!this.shouldUseChunkedUpload(file.size)) {
|
||||
if (!this.shouldUseChunkedUpload(file.size, options.isS3Enabled)) {
|
||||
throw new Error(
|
||||
`File ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)}MB) should not use chunked upload. Use regular upload instead.`
|
||||
);
|
||||
@@ -238,8 +239,13 @@ export class ChunkedUploader {
|
||||
|
||||
/**
|
||||
* Check if file should use chunked upload
|
||||
* Only use chunked upload for filesystem storage, not for S3
|
||||
*/
|
||||
static shouldUseChunkedUpload(fileSize: number): boolean {
|
||||
static shouldUseChunkedUpload(fileSize: number, isS3Enabled?: boolean): boolean {
|
||||
if (isS3Enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const threshold = 100 * 1024 * 1024; // 100MB
|
||||
const shouldUse = fileSize > threshold;
|
||||
|
||||
|
Reference in New Issue
Block a user