mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-27 07:14:56 +00:00
Merge branch 'main' into copilot/fix-pre-deployment-validation
This commit is contained in:
67
ISSUE_COMMENT_TEMPLATE.md
Normal file
67
ISSUE_COMMENT_TEMPLATE.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Issue Comment for Renovate Dependency Dashboard
|
||||
|
||||
**Copy the text below to add as a comment to the Dependency Dashboard issue:**
|
||||
|
||||
---
|
||||
|
||||
## ✅ Dependency Update Status - All Checked Items Applied
|
||||
|
||||
I've reviewed the Dependency Dashboard and verified the status of all checked dependency updates. Here's the current state:
|
||||
|
||||
### ✅ Successfully Applied Updates
|
||||
|
||||
All checked rate-limited updates have been applied to the repository:
|
||||
|
||||
| Package | Version | Status |
|
||||
|---------|---------|--------|
|
||||
| `motion` (replacing framer-motion) | ^12.6.2 | ✅ Applied |
|
||||
| `typescript-eslint` | v8.50.1 | ✅ Applied |
|
||||
| `three` | ^0.182.0 | ✅ Applied |
|
||||
| `actions/checkout` | v6 | ✅ Applied |
|
||||
|
||||
### ❌ Not Applicable: lucide-react
|
||||
|
||||
The `lucide-react` update should **not** be applied. Per our [UI Standards](./UI_STANDARDS.md), this project uses:
|
||||
- ✅ `@mui/icons-material` for icons
|
||||
- ❌ Not `lucide-react`
|
||||
|
||||
Recommendation: Close any Renovate PRs for `lucide-react` as this dependency is not used in our architecture.
|
||||
|
||||
### 📋 Additional Major Version Updates
|
||||
|
||||
The following major version updates mentioned in the dashboard are also current:
|
||||
|
||||
- `@hookform/resolvers` v5.2.2 ✅
|
||||
- `@octokit/core` v7.0.6 ✅
|
||||
- `date-fns` v4.1.0 ✅
|
||||
- `recharts` v3.6.0 ✅
|
||||
- `zod` v4.2.1 ✅
|
||||
- `@prisma/client` & `prisma` v7.2.0 ✅
|
||||
|
||||
### 📝 Deprecation: @types/jszip
|
||||
|
||||
`@types/jszip` is marked as deprecated with no replacement available. We're continuing to use:
|
||||
- `jszip` ^3.10.1 (latest stable)
|
||||
- `@types/jszip` ^3.4.1 (for TypeScript support)
|
||||
|
||||
This is acceptable as the types package remains functional and the core `jszip` library is actively maintained.
|
||||
|
||||
### ✅ Verification
|
||||
|
||||
All updates have been verified:
|
||||
- ✅ Dependencies installed successfully
|
||||
- ✅ Prisma client generated (v7.2.0)
|
||||
- ✅ Linter passes
|
||||
- ✅ Unit tests pass (426/429 tests passing, 3 pre-existing failures)
|
||||
|
||||
### 📄 Full Report
|
||||
|
||||
See [RENOVATE_DASHBOARD_STATUS.md](./RENOVATE_DASHBOARD_STATUS.md) for complete analysis and verification details.
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:**
|
||||
- Renovate will automatically update this dashboard on its next run
|
||||
- Checked items should be marked as completed
|
||||
- Consider configuring Renovate to skip `lucide-react` updates
|
||||
|
||||
128
RENOVATE_DASHBOARD_STATUS.md
Normal file
128
RENOVATE_DASHBOARD_STATUS.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Renovate Dependency Dashboard - Status Report
|
||||
|
||||
**Date:** December 27, 2024
|
||||
**Repository:** johndoe6345789/metabuilder
|
||||
|
||||
## Executive Summary
|
||||
|
||||
All dependency updates marked as checked in the Renovate Dependency Dashboard have been successfully applied to the repository. The codebase is up-to-date with the latest stable versions of all major dependencies.
|
||||
|
||||
## Checked Items Status
|
||||
|
||||
### ✅ Completed Updates
|
||||
|
||||
| Dependency | Requested Version | Current Version | Status |
|
||||
|------------|------------------|-----------------|---------|
|
||||
| `motion` (replacing `framer-motion`) | ^12.6.2 | ^12.6.2 | ✅ Applied |
|
||||
| `typescript-eslint` | v8.50.1 | ^8.50.1 | ✅ Applied |
|
||||
| `three` | ^0.182.0 | ^0.182.0 | ✅ Applied |
|
||||
| `actions/checkout` | v6 | v6 | ✅ Applied |
|
||||
|
||||
### ❌ Not Applicable
|
||||
|
||||
| Dependency | Status | Reason |
|
||||
|------------|--------|--------|
|
||||
| `lucide-react` | Not Added | Project uses `@mui/icons-material` per UI standards (see UI_STANDARDS.md) |
|
||||
|
||||
## Additional Major Version Updates (Already Applied)
|
||||
|
||||
The following major version updates mentioned in the dashboard have also been applied:
|
||||
|
||||
| Package | Current Version | Notes |
|
||||
|---------|----------------|-------|
|
||||
| `@hookform/resolvers` | v5.2.2 | Latest v5 |
|
||||
| `@octokit/core` | v7.0.6 | Latest v7 |
|
||||
| `date-fns` | v4.1.0 | Latest v4 |
|
||||
| `recharts` | v3.6.0 | Latest v3 |
|
||||
| `zod` | v4.2.1 | Latest v4 |
|
||||
| `@prisma/client` | v7.2.0 | Latest v7 |
|
||||
| `prisma` | v7.2.0 | Latest v7 |
|
||||
|
||||
## Deprecations & Replacements
|
||||
|
||||
### @types/jszip
|
||||
- **Status:** Marked as deprecated
|
||||
- **Replacement:** None available
|
||||
- **Current Action:** Continuing to use `@types/jszip` ^3.4.1 with `jszip` ^3.10.1
|
||||
- **Rationale:** The types package is still functional and necessary for TypeScript support. The core `jszip` package (v3.10.1) is actively maintained and at its latest stable version.
|
||||
|
||||
### framer-motion → motion
|
||||
- **Status:** ✅ Completed
|
||||
- **Current Package:** `motion` ^12.6.2
|
||||
- **Note:** The `motion` package currently depends on `framer-motion` as part of the transition. This is expected behavior during the migration period.
|
||||
|
||||
## GitHub Actions Updates
|
||||
|
||||
All GitHub Actions have been updated to their latest versions:
|
||||
|
||||
- `actions/checkout@v6` ✅
|
||||
- `actions/setup-node@v4` (latest v4)
|
||||
- `actions/upload-artifact@v4` (latest v4)
|
||||
- `actions/github-script@v7` (latest v7)
|
||||
- `actions/setup-python@v5` (latest v5)
|
||||
|
||||
## Verification Steps Performed
|
||||
|
||||
1. ✅ Installed all dependencies successfully
|
||||
2. ✅ Generated Prisma client (v7.2.0) without errors
|
||||
3. ✅ Linter passes (only pre-existing warnings)
|
||||
4. ✅ Unit tests pass (426/429 passing, 3 pre-existing failures unrelated to dependency updates)
|
||||
5. ✅ Package versions verified with `npm list`
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
```
|
||||
Test Files 76 passed (76)
|
||||
Tests 426 passed | 3 failed (429)
|
||||
Status Stable - failing tests are pre-existing
|
||||
```
|
||||
|
||||
The 3 failing tests in `src/hooks/useAuth.test.ts` are pre-existing authentication test issues unrelated to the dependency updates.
|
||||
|
||||
## Architecture-Specific Notes
|
||||
|
||||
### Prisma 7.x Migration
|
||||
The repository has been successfully migrated to Prisma 7.x following the official migration guide:
|
||||
- ✅ Datasource URL removed from schema.prisma
|
||||
- ✅ Prisma config setup in prisma.config.ts
|
||||
- ✅ SQLite adapter (@prisma/adapter-better-sqlite3) installed and configured
|
||||
- ✅ Client generation working correctly
|
||||
|
||||
### UI Framework Standards
|
||||
Per `UI_STANDARDS.md`, the project has standardized on:
|
||||
- Material-UI (`@mui/material`) for components
|
||||
- MUI Icons (`@mui/icons-material`) for icons
|
||||
- SASS modules for custom styling
|
||||
|
||||
Therefore, dependencies like `lucide-react` should not be added.
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Renovate Bot
|
||||
1. **Auto-close PRs** for `lucide-react` updates as this dependency is not used
|
||||
2. **Monitor** `@types/jszip` for when a replacement becomes available
|
||||
3. **Continue tracking** the remaining rate-limited updates
|
||||
|
||||
### For Development Team
|
||||
1. All checked dependency updates are applied and verified
|
||||
2. Repository is in a stable state with updated dependencies
|
||||
3. No immediate action required
|
||||
4. Continue monitoring the Renovate Dashboard for future updates
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Renovate will automatically update the Dashboard issue on its next scheduled run
|
||||
- The checked items should be marked as completed by Renovate
|
||||
- New dependency updates will continue to be tracked automatically
|
||||
|
||||
## References
|
||||
|
||||
- [Dependency Update Summary](./DEPENDENCY_UPDATE_SUMMARY.md)
|
||||
- [UI Standards](./UI_STANDARDS.md)
|
||||
- [Prisma 7.x Migration Guide](https://pris.ly/d/major-version-upgrade)
|
||||
- [Renovate Documentation](https://docs.renovatebot.com/)
|
||||
|
||||
---
|
||||
|
||||
**Prepared by:** GitHub Copilot
|
||||
**PR:** [Link to be added by user]
|
||||
@@ -1,350 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import type { DBALAdapter, AdapterCapabilities } from './adapter'
|
||||
import type { ListOptions, ListResult } from '../core/foundation/types'
|
||||
import { DBALError } from '../core/foundation/errors'
|
||||
|
||||
type PrismaAdapterDialect = 'postgres' | 'mysql' | 'sqlite' | 'generic'
|
||||
|
||||
export interface PrismaAdapterOptions {
|
||||
queryTimeout?: number
|
||||
dialect?: PrismaAdapterDialect
|
||||
}
|
||||
|
||||
export class PrismaAdapter implements DBALAdapter {
|
||||
private prisma: PrismaClient
|
||||
private queryTimeout: number
|
||||
private dialect: PrismaAdapterDialect
|
||||
|
||||
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
|
||||
const inferredDialect = options?.dialect ?? PrismaAdapter.inferDialectFromUrl(databaseUrl)
|
||||
this.dialect = inferredDialect ?? 'generic'
|
||||
this.prisma = new PrismaClient({
|
||||
datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined,
|
||||
})
|
||||
this.queryTimeout = options?.queryTimeout ?? 30000
|
||||
}
|
||||
|
||||
async create(entity: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result = await this.withTimeout(
|
||||
model.create({ data: data as never })
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'create', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async read(entity: string, id: string): Promise<unknown | null> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result = await this.withTimeout(
|
||||
model.findUnique({ where: { id } as never })
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'read', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result = await this.withTimeout(
|
||||
model.update({
|
||||
where: { id } as never,
|
||||
data: data as never
|
||||
})
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'update', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(entity: string, id: string): Promise<boolean> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
await this.withTimeout(
|
||||
model.delete({ where: { id } as never })
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (this.isNotFoundError(error)) {
|
||||
return false
|
||||
}
|
||||
throw this.handleError(error, 'delete', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const page = options?.page || 1
|
||||
const limit = options?.limit || 50
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where = options?.filter ? this.buildWhereClause(options.filter) : undefined
|
||||
const orderBy = options?.sort ? this.buildOrderBy(options.sort) : undefined
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.withTimeout(
|
||||
model.findMany({
|
||||
where: where as never,
|
||||
orderBy: orderBy as never,
|
||||
skip,
|
||||
take: limit,
|
||||
})
|
||||
),
|
||||
this.withTimeout(
|
||||
model.count({ where: where as never })
|
||||
)
|
||||
]) as [unknown[], number]
|
||||
|
||||
return {
|
||||
data: data as unknown[],
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: skip + limit < total,
|
||||
}
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'list', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const where = filter ? this.buildWhereClause(filter) : undefined
|
||||
const result = await this.withTimeout(
|
||||
model.findFirst({ where: where as never })
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'findFirst', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result = await this.withTimeout(
|
||||
model.findUnique({ where: { [field]: value } as never })
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'findByField', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
uniqueField: string,
|
||||
uniqueValue: unknown,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result = await this.withTimeout(
|
||||
model.upsert({
|
||||
where: { [uniqueField]: uniqueValue } as never,
|
||||
create: createData as never,
|
||||
update: updateData as never,
|
||||
})
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'upsert', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result = await this.withTimeout(
|
||||
model.update({
|
||||
where: { [field]: value } as never,
|
||||
data: data as never,
|
||||
})
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'updateByField', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
await this.withTimeout(
|
||||
model.delete({ where: { [field]: value } as never })
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (this.isNotFoundError(error)) {
|
||||
return false
|
||||
}
|
||||
throw this.handleError(error, 'deleteByField', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const where = filter ? this.buildWhereClause(filter) : undefined
|
||||
const result: { count: number } = await this.withTimeout(
|
||||
model.deleteMany({ where: where as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'deleteMany', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const where = this.buildWhereClause(filter)
|
||||
const result: { count: number } = await this.withTimeout(
|
||||
model.updateMany({ where: where as never, data: data as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'updateMany', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const result: { count: number } = await this.withTimeout(
|
||||
model.createMany({ data: data as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'createMany', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.buildCapabilities()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.prisma.$disconnect()
|
||||
}
|
||||
|
||||
private getModel(entity: string): any {
|
||||
const modelName = entity.charAt(0).toLowerCase() + entity.slice(1)
|
||||
const model = (this.prisma as any)[modelName]
|
||||
|
||||
if (!model) {
|
||||
throw DBALError.notFound(`Entity ${entity} not found`)
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private buildWhereClause(filter: Record<string, unknown>): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value === null || value === undefined) {
|
||||
where[key] = null
|
||||
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
where[key] = value
|
||||
} else {
|
||||
where[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
private buildOrderBy(sort: Record<string, 'asc' | 'desc'>): Record<string, string> {
|
||||
return sort
|
||||
}
|
||||
|
||||
private async withTimeout<T>(promise: Promise<T>): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(DBALError.timeout()), this.queryTimeout)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
private isNotFoundError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes('not found')
|
||||
}
|
||||
|
||||
private handleError(error: unknown, operation: string, entity: string): DBALError {
|
||||
if (error instanceof DBALError) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Unique constraint')) {
|
||||
return DBALError.conflict(`${entity} already exists`)
|
||||
}
|
||||
if (error.message.includes('Foreign key constraint')) {
|
||||
return DBALError.validationError('Related resource not found')
|
||||
}
|
||||
if (error.message.includes('not found')) {
|
||||
return DBALError.notFound(`${entity} not found`)
|
||||
}
|
||||
return DBALError.internal(`Database error during ${operation}: ${error.message}`)
|
||||
}
|
||||
|
||||
return DBALError.internal(`Unknown error during ${operation}`)
|
||||
}
|
||||
|
||||
private buildCapabilities(): AdapterCapabilities {
|
||||
const fullTextSearch = this.dialect === 'postgres' || this.dialect === 'mysql'
|
||||
|
||||
return {
|
||||
transactions: true,
|
||||
joins: true,
|
||||
fullTextSearch,
|
||||
ttl: false,
|
||||
jsonQueries: true,
|
||||
aggregations: true,
|
||||
relations: true,
|
||||
}
|
||||
}
|
||||
|
||||
private static inferDialectFromUrl(url?: string): PrismaAdapterDialect | undefined {
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (url.startsWith('postgresql://') || url.startsWith('postgres://')) {
|
||||
return 'postgres'
|
||||
}
|
||||
|
||||
if (url.startsWith('mysql://')) {
|
||||
return 'mysql'
|
||||
}
|
||||
|
||||
if (url.startsWith('file:') || url.startsWith('sqlite://')) {
|
||||
return 'sqlite'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresAdapter extends PrismaAdapter {
|
||||
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
|
||||
super(databaseUrl, { ...options, dialect: 'postgres' })
|
||||
}
|
||||
}
|
||||
|
||||
export class MySQLAdapter extends PrismaAdapter {
|
||||
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
|
||||
super(databaseUrl, { ...options, dialect: 'mysql' })
|
||||
}
|
||||
}
|
||||
38
dbal/development/src/adapters/prisma/context.ts
Normal file
38
dbal/development/src/adapters/prisma/context.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaAdapterDialect, type PrismaAdapterOptions, type PrismaContext } from './types'
|
||||
|
||||
export function createPrismaContext(
|
||||
databaseUrl?: string,
|
||||
options?: PrismaAdapterOptions
|
||||
): PrismaContext {
|
||||
const inferredDialect = options?.dialect ?? inferDialectFromUrl(databaseUrl)
|
||||
const prisma = new PrismaClient({
|
||||
datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
prisma,
|
||||
queryTimeout: options?.queryTimeout ?? 30000,
|
||||
dialect: inferredDialect ?? 'generic'
|
||||
}
|
||||
}
|
||||
|
||||
export function inferDialectFromUrl(url?: string): PrismaAdapterDialect | undefined {
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (url.startsWith('postgresql://') || url.startsWith('postgres://')) {
|
||||
return 'postgres'
|
||||
}
|
||||
|
||||
if (url.startsWith('mysql://')) {
|
||||
return 'mysql'
|
||||
}
|
||||
|
||||
if (url.startsWith('file:') || url.startsWith('sqlite://')) {
|
||||
return 'sqlite'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
121
dbal/development/src/adapters/prisma/index.ts
Normal file
121
dbal/development/src/adapters/prisma/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { DBALAdapter } from '../adapter'
|
||||
import type { ListOptions, ListResult } from '../../core/foundation/types'
|
||||
import { createPrismaContext } from './context'
|
||||
import type { PrismaAdapterOptions, PrismaAdapterDialect, PrismaContext } from './types'
|
||||
import {
|
||||
createRecord,
|
||||
deleteRecord,
|
||||
readRecord,
|
||||
updateRecord
|
||||
} from './operations/crud'
|
||||
import {
|
||||
createMany,
|
||||
deleteByField,
|
||||
deleteMany,
|
||||
updateByField,
|
||||
updateMany,
|
||||
upsertRecord
|
||||
} from './operations/bulk'
|
||||
import {
|
||||
findByField,
|
||||
findFirstRecord,
|
||||
listRecords
|
||||
} from './operations/query'
|
||||
import { buildCapabilities } from './operations/capabilities'
|
||||
|
||||
export class PrismaAdapter implements DBALAdapter {
|
||||
protected context: PrismaContext
|
||||
|
||||
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
|
||||
this.context = createPrismaContext(databaseUrl, options)
|
||||
}
|
||||
|
||||
create(entity: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return createRecord(this.context, entity, data)
|
||||
}
|
||||
|
||||
read(entity: string, id: string): Promise<unknown | null> {
|
||||
return readRecord(this.context, entity, id)
|
||||
}
|
||||
|
||||
update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown> {
|
||||
return updateRecord(this.context, entity, id, data)
|
||||
}
|
||||
|
||||
delete(entity: string, id: string): Promise<boolean> {
|
||||
return deleteRecord(this.context, entity, id)
|
||||
}
|
||||
|
||||
list(entity: string, options?: ListOptions): Promise<ListResult<unknown>> {
|
||||
return listRecords(this.context, entity, options)
|
||||
}
|
||||
|
||||
findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
return findFirstRecord(this.context, entity, filter)
|
||||
}
|
||||
|
||||
findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
return findByField(this.context, entity, field, value)
|
||||
}
|
||||
|
||||
upsert(
|
||||
entity: string,
|
||||
uniqueField: string,
|
||||
uniqueValue: unknown,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
return upsertRecord(this.context, entity, uniqueField, uniqueValue, createData, updateData)
|
||||
}
|
||||
|
||||
updateByField(
|
||||
entity: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
data: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
return updateByField(this.context, entity, field, value, data)
|
||||
}
|
||||
|
||||
deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
return deleteByField(this.context, entity, field, value)
|
||||
}
|
||||
|
||||
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
return deleteMany(this.context, entity, filter)
|
||||
}
|
||||
|
||||
updateMany(
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
data: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
return updateMany(this.context, entity, filter, data)
|
||||
}
|
||||
|
||||
createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
return createMany(this.context, entity, data)
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return Promise.resolve(buildCapabilities(this.context))
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.context.prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresAdapter extends PrismaAdapter {
|
||||
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
|
||||
super(databaseUrl, { ...options, dialect: 'postgres' })
|
||||
}
|
||||
}
|
||||
|
||||
export class MySQLAdapter extends PrismaAdapter {
|
||||
constructor(databaseUrl?: string, options?: PrismaAdapterOptions) {
|
||||
super(databaseUrl, { ...options, dialect: 'mysql' })
|
||||
}
|
||||
}
|
||||
|
||||
export { PrismaAdapterOptions, PrismaAdapterDialect }
|
||||
121
dbal/development/src/adapters/prisma/operations/bulk.ts
Normal file
121
dbal/development/src/adapters/prisma/operations/bulk.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { PrismaContext } from '../types'
|
||||
import { handlePrismaError, buildWhereClause, getModel, withTimeout, isNotFoundError } from './utils'
|
||||
|
||||
export async function upsertRecord(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
uniqueField: string,
|
||||
uniqueValue: unknown,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
return await withTimeout(
|
||||
context,
|
||||
model.upsert({
|
||||
where: { [uniqueField]: uniqueValue } as never,
|
||||
create: createData as never,
|
||||
update: updateData as never,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'upsert', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateByField(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
data: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
return await withTimeout(
|
||||
context,
|
||||
model.update({
|
||||
where: { [field]: value } as never,
|
||||
data: data as never,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'updateByField', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteByField(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
field: string,
|
||||
value: unknown
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
await withTimeout(
|
||||
context,
|
||||
model.delete({ where: { [field]: value } as never })
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return false
|
||||
}
|
||||
throw handlePrismaError(error, 'deleteByField', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMany(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
filter?: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
const where = filter ? buildWhereClause(filter) : undefined
|
||||
const result: { count: number } = await withTimeout(
|
||||
context,
|
||||
model.deleteMany({ where: where as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'deleteMany', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMany(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
filter: Record<string, unknown>,
|
||||
data: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
const where = buildWhereClause(filter)
|
||||
const result: { count: number } = await withTimeout(
|
||||
context,
|
||||
model.updateMany({ where: where as never, data: data as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'updateMany', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMany(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
data: Record<string, unknown>[]
|
||||
): Promise<number> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
const result: { count: number } = await withTimeout(
|
||||
context,
|
||||
model.createMany({ data: data as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'createMany', entity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AdapterCapabilities } from '../adapter'
|
||||
import type { PrismaContext } from '../types'
|
||||
|
||||
export function buildCapabilities(context: PrismaContext): AdapterCapabilities {
|
||||
const fullTextSearch = context.dialect === 'postgres' || context.dialect === 'mysql'
|
||||
|
||||
return {
|
||||
transactions: true,
|
||||
joins: true,
|
||||
fullTextSearch,
|
||||
ttl: false,
|
||||
jsonQueries: true,
|
||||
aggregations: true,
|
||||
relations: true,
|
||||
}
|
||||
}
|
||||
71
dbal/development/src/adapters/prisma/operations/crud.ts
Normal file
71
dbal/development/src/adapters/prisma/operations/crud.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { PrismaContext } from '../types'
|
||||
import { handlePrismaError, getModel, withTimeout, isNotFoundError } from './utils'
|
||||
|
||||
export async function createRecord(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
return await withTimeout(context, model.create({ data: data as never }))
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'create', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRecord(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
id: string
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
return await withTimeout(
|
||||
context,
|
||||
model.findUnique({ where: { id } as never })
|
||||
)
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'read', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRecord(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
return await withTimeout(
|
||||
context,
|
||||
model.update({
|
||||
where: { id } as never,
|
||||
data: data as never
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'update', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRecord(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
await withTimeout(
|
||||
context,
|
||||
model.delete({ where: { id } as never })
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return false
|
||||
}
|
||||
throw handlePrismaError(error, 'delete', entity)
|
||||
}
|
||||
}
|
||||
79
dbal/development/src/adapters/prisma/operations/query.ts
Normal file
79
dbal/development/src/adapters/prisma/operations/query.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ListOptions, ListResult } from '../../core/foundation/types'
|
||||
import type { PrismaContext } from '../types'
|
||||
import { handlePrismaError, buildWhereClause, buildOrderBy, getModel, withTimeout } from './utils'
|
||||
|
||||
export async function listRecords(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
options?: ListOptions
|
||||
): Promise<ListResult<unknown>> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
const page = options?.page || 1
|
||||
const limit = options?.limit || 50
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where = options?.filter ? buildWhereClause(options.filter) : undefined
|
||||
const orderBy = options?.sort ? buildOrderBy(options.sort) : undefined
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
withTimeout(
|
||||
context,
|
||||
model.findMany({
|
||||
where: where as never,
|
||||
orderBy: orderBy as never,
|
||||
skip,
|
||||
take: limit,
|
||||
})
|
||||
),
|
||||
withTimeout(
|
||||
context,
|
||||
model.count({ where: where as never })
|
||||
)
|
||||
]) as [unknown[], number]
|
||||
|
||||
return {
|
||||
data: data as unknown[],
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: skip + limit < total,
|
||||
}
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'list', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function findFirstRecord(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
filter?: Record<string, unknown>
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
const where = filter ? buildWhereClause(filter) : undefined
|
||||
return await withTimeout(
|
||||
context,
|
||||
model.findFirst({ where: where as never })
|
||||
)
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'findFirst', entity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function findByField(
|
||||
context: PrismaContext,
|
||||
entity: string,
|
||||
field: string,
|
||||
value: unknown
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const model = getModel(context, entity)
|
||||
return await withTimeout(
|
||||
context,
|
||||
model.findUnique({ where: { [field]: value } as never })
|
||||
)
|
||||
} catch (error) {
|
||||
throw handlePrismaError(error, 'findByField', entity)
|
||||
}
|
||||
}
|
||||
71
dbal/development/src/adapters/prisma/operations/utils.ts
Normal file
71
dbal/development/src/adapters/prisma/operations/utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { PrismaContext } from '../types'
|
||||
import { DBALError } from '../../core/foundation/errors'
|
||||
|
||||
export function getModel(context: PrismaContext, entity: string): any {
|
||||
const modelName = entity.charAt(0).toLowerCase() + entity.slice(1)
|
||||
const model = (context.prisma as any)[modelName]
|
||||
|
||||
if (!model) {
|
||||
throw DBALError.notFound(`Entity ${entity} not found`)
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
export function buildWhereClause(filter: Record<string, unknown>): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value === null || value === undefined) {
|
||||
where[key] = null
|
||||
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
where[key] = value
|
||||
} else {
|
||||
where[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
export function buildOrderBy(sort: Record<string, 'asc' | 'desc'>): Record<string, string> {
|
||||
return sort
|
||||
}
|
||||
|
||||
export async function withTimeout<T>(context: PrismaContext, promise: Promise<T>): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(DBALError.timeout()), context.queryTimeout)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
export function isNotFoundError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes('not found')
|
||||
}
|
||||
|
||||
export function handlePrismaError(
|
||||
error: unknown,
|
||||
operation: string,
|
||||
entity: string
|
||||
): DBALError {
|
||||
if (error instanceof DBALError) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Unique constraint')) {
|
||||
return DBALError.conflict(`${entity} already exists`)
|
||||
}
|
||||
if (error.message.includes('Foreign key constraint')) {
|
||||
return DBALError.validationError('Related resource not found')
|
||||
}
|
||||
if (error.message.includes('not found')) {
|
||||
return DBALError.notFound(`${entity} not found`)
|
||||
}
|
||||
return DBALError.internal(`Database error during ${operation}: ${error.message}`)
|
||||
}
|
||||
|
||||
return DBALError.internal(`Unknown error during ${operation}`)
|
||||
}
|
||||
38
dbal/development/src/adapters/prisma/types.ts
Normal file
38
dbal/development/src/adapters/prisma/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { AdapterCapabilities } from '../adapter'
|
||||
|
||||
export type PrismaAdapterDialect = 'postgres' | 'mysql' | 'sqlite' | 'generic'
|
||||
|
||||
export interface PrismaAdapterOptions {
|
||||
queryTimeout?: number
|
||||
dialect?: PrismaAdapterDialect
|
||||
}
|
||||
|
||||
export interface PrismaContext {
|
||||
prisma: any
|
||||
queryTimeout: number
|
||||
dialect: PrismaAdapterDialect
|
||||
}
|
||||
|
||||
export interface PrismaOperations {
|
||||
create(entity: string, data: Record<string, unknown>): Promise<unknown>
|
||||
read(entity: string, id: string): Promise<unknown | null>
|
||||
update(entity: string, id: string, data: Record<string, unknown>): Promise<unknown>
|
||||
delete(entity: string, id: string): Promise<boolean>
|
||||
list(entity: string, options?: any): Promise<any>
|
||||
findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null>
|
||||
findByField(entity: string, field: string, value: unknown): Promise<unknown | null>
|
||||
upsert(
|
||||
entity: string,
|
||||
uniqueField: string,
|
||||
uniqueValue: unknown,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>
|
||||
): Promise<unknown>
|
||||
updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown>
|
||||
deleteByField(entity: string, field: string, value: unknown): Promise<boolean>
|
||||
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number>
|
||||
createMany(entity: string, data: Record<string, unknown>[]): Promise<number>
|
||||
updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number>
|
||||
getCapabilities(): Promise<AdapterCapabilities>
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
export * from './blob-storage'
|
||||
export { MemoryStorage } from './providers/memory-storage'
|
||||
export { S3Storage } from './providers/s3-storage'
|
||||
export { FilesystemStorage } from './providers/filesystem-storage'
|
||||
export { S3Storage } from './providers/s3'
|
||||
export { FilesystemStorage } from './providers/filesystem'
|
||||
export { TenantAwareBlobStorage } from './providers/tenant-aware-storage'
|
||||
|
||||
import type { BlobStorage, BlobStorageConfig } from './blob-storage'
|
||||
import { MemoryStorage } from './providers/memory-storage'
|
||||
import { S3Storage } from './providers/s3-storage'
|
||||
import { FilesystemStorage } from './providers/filesystem-storage'
|
||||
import { S3Storage } from './providers/s3'
|
||||
import { FilesystemStorage } from './providers/filesystem'
|
||||
|
||||
/**
|
||||
* Factory function to create blob storage instances
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import type {
|
||||
BlobStorage,
|
||||
BlobMetadata,
|
||||
BlobListResult,
|
||||
UploadOptions,
|
||||
DownloadOptions,
|
||||
BlobListOptions,
|
||||
BlobStorageConfig,
|
||||
} from '../blob-storage'
|
||||
import { DBALError } from '../../core/foundation/errors'
|
||||
import { promises as fs } from 'fs'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import path from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import { pipeline } from 'stream/promises'
|
||||
|
||||
/**
|
||||
* Filesystem blob storage implementation
|
||||
* Compatible with local filesystem, Samba/CIFS, NFS
|
||||
*/
|
||||
export class FilesystemStorage implements BlobStorage {
|
||||
private basePath: string
|
||||
|
||||
constructor(config: BlobStorageConfig) {
|
||||
if (!config.filesystem) {
|
||||
throw new Error('Filesystem configuration required')
|
||||
}
|
||||
|
||||
this.basePath = config.filesystem.basePath
|
||||
|
||||
if (config.filesystem.createIfNotExists) {
|
||||
this.ensureBasePath()
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBasePath() {
|
||||
try {
|
||||
await fs.mkdir(this.basePath, { recursive: true })
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to create base path: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getFullPath(key: string): string {
|
||||
// Prevent directory traversal attacks
|
||||
const normalized = path.normalize(key).replace(/^(\.\.(\/|\\|$))+/, '')
|
||||
return path.join(this.basePath, normalized)
|
||||
}
|
||||
|
||||
private getMetadataPath(key: string): string {
|
||||
return this.getFullPath(key) + '.meta.json'
|
||||
}
|
||||
|
||||
async upload(
|
||||
key: string,
|
||||
data: Buffer | Uint8Array,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
const filePath = this.getFullPath(key)
|
||||
const metaPath = this.getMetadataPath(key)
|
||||
|
||||
try {
|
||||
// Create directory if needed
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
// Check if file exists and overwrite is false
|
||||
if (!options.overwrite) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
throw DBALError.conflict(`Blob already exists: ${key}`)
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(filePath, data)
|
||||
|
||||
// Generate metadata
|
||||
const buffer = Buffer.from(data)
|
||||
const etag = this.generateEtag(buffer)
|
||||
const metadata: BlobMetadata = {
|
||||
key,
|
||||
size: buffer.length,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag,
|
||||
lastModified: new Date(),
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
|
||||
|
||||
return metadata
|
||||
} catch (error: any) {
|
||||
if (error instanceof DBALError) {
|
||||
throw error
|
||||
}
|
||||
throw DBALError.internal(`Filesystem upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async uploadStream(
|
||||
key: string,
|
||||
stream: ReadableStream | NodeJS.ReadableStream,
|
||||
size: number,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
const filePath = this.getFullPath(key)
|
||||
const metaPath = this.getMetadataPath(key)
|
||||
|
||||
try {
|
||||
// Create directory if needed
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
// Check if file exists and overwrite is false
|
||||
if (!options.overwrite) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
throw DBALError.conflict(`Blob already exists: ${key}`)
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write stream to file
|
||||
const writeStream = createWriteStream(filePath)
|
||||
|
||||
if ('getReader' in stream) {
|
||||
// Web ReadableStream
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
writeStream.write(Buffer.from(value))
|
||||
}
|
||||
writeStream.end()
|
||||
} else {
|
||||
// Node.js ReadableStream
|
||||
await pipeline(stream, writeStream)
|
||||
}
|
||||
|
||||
// Get file stats for actual size
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
// Generate etag from file
|
||||
const buffer = await fs.readFile(filePath)
|
||||
const etag = this.generateEtag(buffer)
|
||||
|
||||
const metadata: BlobMetadata = {
|
||||
key,
|
||||
size: stats.size,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag,
|
||||
lastModified: stats.mtime,
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
|
||||
|
||||
return metadata
|
||||
} catch (error: any) {
|
||||
if (error instanceof DBALError) {
|
||||
throw error
|
||||
}
|
||||
throw DBALError.internal(`Filesystem stream upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async download(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<Buffer> {
|
||||
const filePath = this.getFullPath(key)
|
||||
|
||||
try {
|
||||
let data = await fs.readFile(filePath)
|
||||
|
||||
if (options.offset !== undefined || options.length !== undefined) {
|
||||
const offset = options.offset || 0
|
||||
const length = options.length || (data.length - offset)
|
||||
|
||||
if (offset >= data.length) {
|
||||
throw DBALError.validationError('Offset exceeds blob size')
|
||||
}
|
||||
|
||||
data = data.subarray(offset, offset + length)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
if (error instanceof DBALError) {
|
||||
throw error
|
||||
}
|
||||
throw DBALError.internal(`Filesystem download failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async downloadStream(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
const filePath = this.getFullPath(key)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
|
||||
const streamOptions: any = {}
|
||||
if (options.offset !== undefined) {
|
||||
streamOptions.start = options.offset
|
||||
}
|
||||
if (options.length !== undefined) {
|
||||
streamOptions.end = (options.offset || 0) + options.length - 1
|
||||
}
|
||||
|
||||
return createReadStream(filePath, streamOptions)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem download stream failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const filePath = this.getFullPath(key)
|
||||
const metaPath = this.getMetadataPath(key)
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
|
||||
// Try to delete metadata (ignore if doesn't exist)
|
||||
try {
|
||||
await fs.unlink(metaPath)
|
||||
} catch (error: any) {
|
||||
// Ignore if metadata doesn't exist
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem delete failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const filePath = this.getFullPath(key)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(key: string): Promise<BlobMetadata> {
|
||||
const filePath = this.getFullPath(key)
|
||||
const metaPath = this.getMetadataPath(key)
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
// Try to read metadata file
|
||||
try {
|
||||
const metaContent = await fs.readFile(metaPath, 'utf-8')
|
||||
return JSON.parse(metaContent)
|
||||
} catch {
|
||||
// Generate metadata from file if meta file doesn't exist
|
||||
const data = await fs.readFile(filePath)
|
||||
return {
|
||||
key,
|
||||
size: stats.size,
|
||||
contentType: 'application/octet-stream',
|
||||
etag: this.generateEtag(data),
|
||||
lastModified: stats.mtime,
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem get metadata failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
|
||||
const prefix = options.prefix || ''
|
||||
const maxKeys = options.maxKeys || 1000
|
||||
|
||||
try {
|
||||
const items: BlobMetadata[] = []
|
||||
await this.walkDirectory(this.basePath, prefix, maxKeys, items)
|
||||
|
||||
return {
|
||||
items: items.slice(0, maxKeys),
|
||||
isTruncated: items.length > maxKeys,
|
||||
nextToken: items.length > maxKeys ? items[maxKeys].key : undefined,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`Filesystem list failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async walkDirectory(
|
||||
dir: string,
|
||||
prefix: string,
|
||||
maxKeys: number,
|
||||
items: BlobMetadata[]
|
||||
) {
|
||||
if (items.length >= maxKeys) return
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (items.length >= maxKeys) break
|
||||
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDirectory(fullPath, prefix, maxKeys, items)
|
||||
} else if (!entry.name.endsWith('.meta.json')) {
|
||||
const relativePath = path.relative(this.basePath, fullPath)
|
||||
const normalizedKey = relativePath.split(path.sep).join('/')
|
||||
|
||||
if (!prefix || normalizedKey.startsWith(prefix)) {
|
||||
try {
|
||||
const metadata = await this.getMetadata(normalizedKey)
|
||||
items.push(metadata)
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async generatePresignedUrl(
|
||||
key: string,
|
||||
expirationSeconds: number = 3600
|
||||
): Promise<string> {
|
||||
// Filesystem storage doesn't support presigned URLs
|
||||
return ''
|
||||
}
|
||||
|
||||
async copy(
|
||||
sourceKey: string,
|
||||
destKey: string
|
||||
): Promise<BlobMetadata> {
|
||||
const sourcePath = this.getFullPath(sourceKey)
|
||||
const destPath = this.getFullPath(destKey)
|
||||
const sourceMetaPath = this.getMetadataPath(sourceKey)
|
||||
const destMetaPath = this.getMetadataPath(destKey)
|
||||
|
||||
try {
|
||||
// Create destination directory if needed
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
||||
|
||||
// Copy file
|
||||
await fs.copyFile(sourcePath, destPath)
|
||||
|
||||
// Copy or regenerate metadata
|
||||
try {
|
||||
await fs.copyFile(sourceMetaPath, destMetaPath)
|
||||
|
||||
// Update lastModified in metadata
|
||||
const metadata = JSON.parse(await fs.readFile(destMetaPath, 'utf-8'))
|
||||
metadata.lastModified = new Date()
|
||||
metadata.key = destKey
|
||||
await fs.writeFile(destMetaPath, JSON.stringify(metadata, null, 2))
|
||||
|
||||
return metadata
|
||||
} catch {
|
||||
// Regenerate metadata if copy fails
|
||||
return await this.getMetadata(destKey)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem copy failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getTotalSize(): Promise<number> {
|
||||
const items = await this.list({ maxKeys: Number.MAX_SAFE_INTEGER })
|
||||
return items.items.reduce((sum, item) => sum + item.size, 0)
|
||||
}
|
||||
|
||||
async getObjectCount(): Promise<number> {
|
||||
const items = await this.list({ maxKeys: Number.MAX_SAFE_INTEGER })
|
||||
return items.items.length
|
||||
}
|
||||
|
||||
private generateEtag(data: Buffer): string {
|
||||
const hash = createHash('md5').update(data).digest('hex')
|
||||
return `"${hash}"`
|
||||
}
|
||||
}
|
||||
28
dbal/development/src/blob/providers/filesystem/context.ts
Normal file
28
dbal/development/src/blob/providers/filesystem/context.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { BlobStorageConfig } from '../../blob-storage'
|
||||
import { promises as fs } from 'fs'
|
||||
|
||||
export interface FilesystemContext {
|
||||
basePath: string
|
||||
}
|
||||
|
||||
export function createFilesystemContext(config: BlobStorageConfig): FilesystemContext {
|
||||
if (!config.filesystem) {
|
||||
throw new Error('Filesystem configuration required')
|
||||
}
|
||||
|
||||
const basePath = config.filesystem.basePath
|
||||
|
||||
if (config.filesystem.createIfNotExists) {
|
||||
void ensureBasePath(basePath)
|
||||
}
|
||||
|
||||
return { basePath }
|
||||
}
|
||||
|
||||
async function ensureBasePath(basePath: string) {
|
||||
try {
|
||||
await fs.mkdir(basePath, { recursive: true })
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to create base path: ${error.message}`)
|
||||
}
|
||||
}
|
||||
98
dbal/development/src/blob/providers/filesystem/index.ts
Normal file
98
dbal/development/src/blob/providers/filesystem/index.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import type {
|
||||
BlobStorage,
|
||||
BlobMetadata,
|
||||
BlobListResult,
|
||||
UploadOptions,
|
||||
DownloadOptions,
|
||||
BlobListOptions,
|
||||
BlobStorageConfig,
|
||||
} from '../../blob-storage'
|
||||
import { createFilesystemContext, type FilesystemContext } from './context'
|
||||
import { buildFullPath } from './paths'
|
||||
import { copyBlob, deleteBlob, objectCount, totalSize } from './operations/maintenance'
|
||||
import { downloadBuffer, downloadStream } from './operations/downloads'
|
||||
import { readMetadata } from './operations/metadata'
|
||||
import { listBlobs } from './operations/listing'
|
||||
import { uploadBuffer, uploadStream } from './operations/uploads'
|
||||
|
||||
export class FilesystemStorage implements BlobStorage {
|
||||
private readonly context: FilesystemContext
|
||||
|
||||
constructor(config: BlobStorageConfig) {
|
||||
this.context = createFilesystemContext(config)
|
||||
}
|
||||
|
||||
upload(
|
||||
key: string,
|
||||
data: Buffer | Uint8Array,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
return uploadBuffer(this.context, key, data, options)
|
||||
}
|
||||
|
||||
uploadStream(
|
||||
key: string,
|
||||
stream: ReadableStream | NodeJS.ReadableStream,
|
||||
size: number,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
return uploadStream(this.context, key, stream, size, options)
|
||||
}
|
||||
|
||||
download(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<Buffer> {
|
||||
return downloadBuffer(this.context, key, options)
|
||||
}
|
||||
|
||||
downloadStream(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
return downloadStream(this.context, key, options)
|
||||
}
|
||||
|
||||
delete(key: string): Promise<boolean> {
|
||||
return deleteBlob(this.context, key)
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const filePath = buildFullPath(this.context.basePath, key)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getMetadata(key: string): Promise<BlobMetadata> {
|
||||
return readMetadata(this.context, key)
|
||||
}
|
||||
|
||||
list(options: BlobListOptions = {}): Promise<BlobListResult> {
|
||||
return listBlobs(this.context, options)
|
||||
}
|
||||
|
||||
async generatePresignedUrl(
|
||||
key: string,
|
||||
expirationSeconds: number = 3600
|
||||
): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
|
||||
copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
|
||||
return copyBlob(this.context, sourceKey, destKey)
|
||||
}
|
||||
|
||||
getTotalSize(): Promise<number> {
|
||||
return totalSize(this.context)
|
||||
}
|
||||
|
||||
getObjectCount(): Promise<number> {
|
||||
return objectCount(this.context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { promises as fs, createReadStream } from 'fs'
|
||||
import type { DownloadOptions } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { FilesystemContext } from '../context'
|
||||
import { buildFullPath } from '../paths'
|
||||
|
||||
export async function downloadBuffer(
|
||||
context: FilesystemContext,
|
||||
key: string,
|
||||
options: DownloadOptions
|
||||
): Promise<Buffer> {
|
||||
const filePath = buildFullPath(context.basePath, key)
|
||||
|
||||
try {
|
||||
let data = await fs.readFile(filePath)
|
||||
|
||||
if (options.offset !== undefined || options.length !== undefined) {
|
||||
const offset = options.offset || 0
|
||||
const length = options.length || (data.length - offset)
|
||||
|
||||
if (offset >= data.length) {
|
||||
throw DBALError.validationError('Offset exceeds blob size')
|
||||
}
|
||||
|
||||
data = data.subarray(offset, offset + length)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
if (error instanceof DBALError) {
|
||||
throw error
|
||||
}
|
||||
throw DBALError.internal(`Filesystem download failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadStream(
|
||||
context: FilesystemContext,
|
||||
key: string,
|
||||
options: DownloadOptions
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
const filePath = buildFullPath(context.basePath, key)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
|
||||
const streamOptions: any = {}
|
||||
if (options.offset !== undefined) {
|
||||
streamOptions.start = options.offset
|
||||
}
|
||||
if (options.length !== undefined) {
|
||||
streamOptions.end = (options.offset || 0) + options.length - 1
|
||||
}
|
||||
|
||||
return createReadStream(filePath, streamOptions)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem download stream failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { FilesystemContext } from '../context'
|
||||
import { buildFullPath } from '../paths'
|
||||
import { readMetadata } from './metadata'
|
||||
|
||||
export async function listBlobs(
|
||||
context: FilesystemContext,
|
||||
options: BlobListOptions
|
||||
): Promise<BlobListResult> {
|
||||
const prefix = options.prefix || ''
|
||||
const maxKeys = options.maxKeys || 1000
|
||||
|
||||
try {
|
||||
const items: BlobMetadata[] = []
|
||||
await walkDirectory(context, context.basePath, prefix, maxKeys, items)
|
||||
|
||||
return {
|
||||
items: items.slice(0, maxKeys),
|
||||
isTruncated: items.length > maxKeys,
|
||||
nextToken: items.length > maxKeys ? items[maxKeys].key : undefined,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`Filesystem list failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function walkDirectory(
|
||||
context: FilesystemContext,
|
||||
dir: string,
|
||||
prefix: string,
|
||||
maxKeys: number,
|
||||
items: BlobMetadata[]
|
||||
) {
|
||||
if (items.length >= maxKeys) return
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (items.length >= maxKeys) break
|
||||
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walkDirectory(context, fullPath, prefix, maxKeys, items)
|
||||
} else if (!entry.name.endsWith('.meta.json')) {
|
||||
const relativePath = path.relative(context.basePath, fullPath)
|
||||
const normalizedKey = relativePath.split(path.sep).join('/')
|
||||
|
||||
if (!prefix || normalizedKey.startsWith(prefix)) {
|
||||
try {
|
||||
const metadata = await readMetadata(context, normalizedKey)
|
||||
items.push(metadata)
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import type { BlobMetadata } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { FilesystemContext } from '../context'
|
||||
import { buildFullPath, buildMetadataPath } from '../paths'
|
||||
import { readMetadata } from './metadata'
|
||||
import { listBlobs } from './listing'
|
||||
|
||||
export async function deleteBlob(
|
||||
context: FilesystemContext,
|
||||
key: string
|
||||
): Promise<boolean> {
|
||||
const filePath = buildFullPath(context.basePath, key)
|
||||
const metaPath = buildMetadataPath(context.basePath, key)
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
|
||||
try {
|
||||
await fs.unlink(metaPath)
|
||||
} catch {
|
||||
// Ignore missing metadata files
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem delete failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyBlob(
|
||||
context: FilesystemContext,
|
||||
sourceKey: string,
|
||||
destKey: string
|
||||
): Promise<BlobMetadata> {
|
||||
const sourcePath = buildFullPath(context.basePath, sourceKey)
|
||||
const destPath = buildFullPath(context.basePath, destKey)
|
||||
const sourceMetaPath = buildMetadataPath(context.basePath, sourceKey)
|
||||
const destMetaPath = buildMetadataPath(context.basePath, destKey)
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
||||
await fs.copyFile(sourcePath, destPath)
|
||||
|
||||
try {
|
||||
await fs.copyFile(sourceMetaPath, destMetaPath)
|
||||
const metadata = JSON.parse(await fs.readFile(destMetaPath, 'utf-8'))
|
||||
metadata.lastModified = new Date()
|
||||
metadata.key = destKey
|
||||
await fs.writeFile(destMetaPath, JSON.stringify(metadata, null, 2))
|
||||
return metadata
|
||||
} catch {
|
||||
return await readMetadata(context, destKey)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem copy failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function totalSize(context: FilesystemContext): Promise<number> {
|
||||
const items = await listBlobs(context, { maxKeys: Number.MAX_SAFE_INTEGER })
|
||||
return items.items.reduce((sum, item) => sum + item.size, 0)
|
||||
}
|
||||
|
||||
export async function objectCount(context: FilesystemContext): Promise<number> {
|
||||
const items = await listBlobs(context, { maxKeys: Number.MAX_SAFE_INTEGER })
|
||||
return items.items.length
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { createHash } from 'crypto'
|
||||
import type { BlobMetadata } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { FilesystemContext } from '../context'
|
||||
import { buildFullPath, buildMetadataPath } from '../paths'
|
||||
|
||||
export async function readMetadata(
|
||||
context: FilesystemContext,
|
||||
key: string
|
||||
): Promise<BlobMetadata> {
|
||||
const filePath = buildFullPath(context.basePath, key)
|
||||
const metaPath = buildMetadataPath(context.basePath, key)
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
try {
|
||||
const metaContent = await fs.readFile(metaPath, 'utf-8')
|
||||
return JSON.parse(metaContent)
|
||||
} catch {
|
||||
const data = await fs.readFile(filePath)
|
||||
return {
|
||||
key,
|
||||
size: stats.size,
|
||||
contentType: 'application/octet-stream',
|
||||
etag: generateEtag(data),
|
||||
lastModified: stats.mtime,
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`Filesystem get metadata failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMetadata(
|
||||
context: FilesystemContext,
|
||||
key: string,
|
||||
metadata: BlobMetadata
|
||||
) {
|
||||
const metaPath = buildMetadataPath(context.basePath, key)
|
||||
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
|
||||
}
|
||||
|
||||
export function generateEtag(data: Buffer): string {
|
||||
const hash = createHash('md5').update(data).digest('hex')
|
||||
return `"${hash}"`
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { promises as fs, createWriteStream } from 'fs'
|
||||
import path from 'path'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import type { BlobMetadata, UploadOptions } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { FilesystemContext } from '../context'
|
||||
import { buildFullPath, buildMetadataPath } from '../paths'
|
||||
import { generateEtag, writeMetadata } from './metadata'
|
||||
|
||||
async function ensureWritableDestination(
|
||||
filePath: string,
|
||||
overwrite?: boolean
|
||||
) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
if (!overwrite) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
throw DBALError.conflict(`Blob already exists: ${filePath}`)
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadBuffer(
|
||||
context: FilesystemContext,
|
||||
key: string,
|
||||
data: Buffer | Uint8Array,
|
||||
options: UploadOptions
|
||||
): Promise<BlobMetadata> {
|
||||
const filePath = buildFullPath(context.basePath, key)
|
||||
const metaPath = buildMetadataPath(context.basePath, key)
|
||||
|
||||
try {
|
||||
await ensureWritableDestination(filePath, options.overwrite)
|
||||
|
||||
await fs.writeFile(filePath, data)
|
||||
|
||||
const buffer = Buffer.from(data)
|
||||
const metadata: BlobMetadata = {
|
||||
key,
|
||||
size: buffer.length,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag: generateEtag(buffer),
|
||||
lastModified: new Date(),
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
|
||||
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2))
|
||||
|
||||
return metadata
|
||||
} catch (error: any) {
|
||||
if (error instanceof DBALError) {
|
||||
throw error
|
||||
}
|
||||
throw DBALError.internal(`Filesystem upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadStream(
|
||||
context: FilesystemContext,
|
||||
key: string,
|
||||
stream: ReadableStream | NodeJS.ReadableStream,
|
||||
size: number,
|
||||
options: UploadOptions
|
||||
): Promise<BlobMetadata> {
|
||||
const filePath = buildFullPath(context.basePath, key)
|
||||
|
||||
try {
|
||||
await ensureWritableDestination(filePath, options.overwrite)
|
||||
|
||||
const writeStream = createWriteStream(filePath)
|
||||
|
||||
if ('getReader' in stream) {
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
writeStream.write(Buffer.from(value))
|
||||
}
|
||||
writeStream.end()
|
||||
} else {
|
||||
await pipeline(stream, writeStream)
|
||||
}
|
||||
|
||||
const stats = await fs.stat(filePath)
|
||||
const buffer = await fs.readFile(filePath)
|
||||
const metadata: BlobMetadata = {
|
||||
key,
|
||||
size: stats.size,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag: generateEtag(buffer),
|
||||
lastModified: stats.mtime,
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
|
||||
await writeMetadata(context, key, metadata)
|
||||
|
||||
return metadata
|
||||
} catch (error: any) {
|
||||
if (error instanceof DBALError) {
|
||||
throw error
|
||||
}
|
||||
throw DBALError.internal(`Filesystem stream upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
11
dbal/development/src/blob/providers/filesystem/paths.ts
Normal file
11
dbal/development/src/blob/providers/filesystem/paths.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import path from 'path'
|
||||
import { sanitizeKey } from './sanitize-key'
|
||||
|
||||
export function buildFullPath(basePath: string, key: string): string {
|
||||
const normalized = sanitizeKey(key)
|
||||
return path.join(basePath, normalized)
|
||||
}
|
||||
|
||||
export function buildMetadataPath(basePath: string, key: string): string {
|
||||
return buildFullPath(basePath, key) + '.meta.json'
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function sanitizeKey(key: string): string {
|
||||
return key.replace(/^(\.\.(\/|\\|$))+/, '')
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
import type {
|
||||
BlobStorage,
|
||||
BlobMetadata,
|
||||
BlobListResult,
|
||||
UploadOptions,
|
||||
DownloadOptions,
|
||||
BlobListOptions,
|
||||
BlobStorageConfig,
|
||||
} from '../blob-storage'
|
||||
import { DBALError } from '../../core/foundation/errors'
|
||||
|
||||
/**
|
||||
* S3-compatible blob storage implementation
|
||||
* Uses AWS SDK v3 for S3 operations
|
||||
* Compatible with MinIO and other S3-compatible services
|
||||
*/
|
||||
export class S3Storage implements BlobStorage {
|
||||
private s3Client: any
|
||||
private bucket: string
|
||||
|
||||
constructor(config: BlobStorageConfig) {
|
||||
if (!config.s3) {
|
||||
throw new Error('S3 configuration required')
|
||||
}
|
||||
|
||||
this.bucket = config.s3.bucket
|
||||
|
||||
// Lazy-load AWS SDK to avoid bundling if not used
|
||||
this.initializeS3Client(config.s3)
|
||||
}
|
||||
|
||||
private async initializeS3Client(s3Config: NonNullable<BlobStorageConfig['s3']>) {
|
||||
try {
|
||||
// Dynamic import to avoid bundling AWS SDK if not installed
|
||||
// @ts-ignore - Optional dependency
|
||||
const s3Module = await import('@aws-sdk/client-s3').catch(() => null)
|
||||
if (!s3Module) {
|
||||
throw new Error('@aws-sdk/client-s3 is not installed. Install it with: npm install @aws-sdk/client-s3')
|
||||
}
|
||||
const { S3Client } = s3Module
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region: s3Config.region,
|
||||
credentials: s3Config.accessKeyId && s3Config.secretAccessKey ? {
|
||||
accessKeyId: s3Config.accessKeyId,
|
||||
secretAccessKey: s3Config.secretAccessKey,
|
||||
} : undefined,
|
||||
endpoint: s3Config.endpoint,
|
||||
forcePathStyle: s3Config.forcePathStyle,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error('AWS SDK @aws-sdk/client-s3 not installed. Install with: npm install @aws-sdk/client-s3')
|
||||
}
|
||||
}
|
||||
|
||||
async upload(
|
||||
key: string,
|
||||
data: Buffer | Uint8Array,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: options.contentType,
|
||||
Metadata: options.metadata,
|
||||
})
|
||||
|
||||
const response = await this.s3Client.send(command)
|
||||
|
||||
return {
|
||||
key,
|
||||
size: data.length,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag: response.ETag || '',
|
||||
lastModified: new Date(),
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchBucket') {
|
||||
throw DBALError.notFound(`Bucket not found: ${this.bucket}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async uploadStream(
|
||||
key: string,
|
||||
stream: ReadableStream | NodeJS.ReadableStream,
|
||||
size: number,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { Upload } = await import('@aws-sdk/lib-storage')
|
||||
|
||||
const upload = new Upload({
|
||||
client: this.s3Client,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: stream as any, // Type compatibility between Node.js and Web streams
|
||||
ContentType: options.contentType,
|
||||
Metadata: options.metadata,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await upload.done()
|
||||
|
||||
return {
|
||||
key,
|
||||
size,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag: response.ETag || '',
|
||||
lastModified: new Date(),
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 stream upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async download(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const range = this.buildRangeHeader(options)
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Range: range,
|
||||
})
|
||||
|
||||
const response = await this.s3Client.send(command)
|
||||
|
||||
// Convert stream to buffer
|
||||
const chunks: Uint8Array[] = []
|
||||
for await (const chunk of response.Body as any) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks)
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 download failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async downloadStream(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<ReadableStream | NodeJS.ReadableStream> {
|
||||
try {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const range = this.buildRangeHeader(options)
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Range: range,
|
||||
})
|
||||
|
||||
const response = await this.s3Client.send(command)
|
||||
return response.Body as any
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 download stream failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
try {
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
await this.s3Client.send(command)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 delete failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getMetadata(key)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 404) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(key: string): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
const response = await this.s3Client.send(command)
|
||||
|
||||
return {
|
||||
key,
|
||||
size: response.ContentLength || 0,
|
||||
contentType: response.ContentType || 'application/octet-stream',
|
||||
etag: response.ETag || '',
|
||||
lastModified: response.LastModified || new Date(),
|
||||
customMetadata: response.Metadata,
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NotFound') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 head object failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
|
||||
try {
|
||||
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: options.prefix,
|
||||
ContinuationToken: options.continuationToken,
|
||||
MaxKeys: options.maxKeys || 1000,
|
||||
})
|
||||
|
||||
const response = await this.s3Client.send(command)
|
||||
|
||||
const items: BlobMetadata[] = (response.Contents || []).map(obj => ({
|
||||
key: obj.Key || '',
|
||||
size: obj.Size || 0,
|
||||
contentType: 'application/octet-stream', // S3 list doesn't return content type
|
||||
etag: obj.ETag || '',
|
||||
lastModified: obj.LastModified || new Date(),
|
||||
}))
|
||||
|
||||
return {
|
||||
items,
|
||||
nextToken: response.NextContinuationToken,
|
||||
isTruncated: response.IsTruncated || false,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 list failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async generatePresignedUrl(
|
||||
key: string,
|
||||
expirationSeconds: number = 3600
|
||||
): Promise<string> {
|
||||
try {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
return await getSignedUrl(this.s3Client, command, {
|
||||
expiresIn: expirationSeconds,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 presigned URL generation failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async copy(
|
||||
sourceKey: string,
|
||||
destKey: string
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { CopyObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
CopySource: `${this.bucket}/${sourceKey}`,
|
||||
Key: destKey,
|
||||
})
|
||||
|
||||
const response = await this.s3Client.send(command)
|
||||
|
||||
return await this.getMetadata(destKey)
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 copy failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getTotalSize(): Promise<number> {
|
||||
// Note: This requires listing all objects and summing sizes
|
||||
// For large buckets, this can be expensive
|
||||
const result = await this.list({ maxKeys: 1000 })
|
||||
let total = result.items.reduce((sum, item) => sum + item.size, 0)
|
||||
|
||||
// Handle pagination if needed
|
||||
let nextToken = result.nextToken
|
||||
while (nextToken) {
|
||||
const pageResult = await this.list({
|
||||
maxKeys: 1000,
|
||||
continuationToken: nextToken
|
||||
})
|
||||
total += pageResult.items.reduce((sum, item) => sum + item.size, 0)
|
||||
nextToken = pageResult.nextToken
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
async getObjectCount(): Promise<number> {
|
||||
// Similar to getTotalSize, requires listing
|
||||
const result = await this.list({ maxKeys: 1000 })
|
||||
let count = result.items.length
|
||||
|
||||
let nextToken = result.nextToken
|
||||
while (nextToken) {
|
||||
const pageResult = await this.list({
|
||||
maxKeys: 1000,
|
||||
continuationToken: nextToken
|
||||
})
|
||||
count += pageResult.items.length
|
||||
nextToken = pageResult.nextToken
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
private buildRangeHeader(options: DownloadOptions): string | undefined {
|
||||
if (options.offset === undefined && options.length === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const offset = options.offset || 0
|
||||
const end = options.length !== undefined ? offset + options.length - 1 : undefined
|
||||
|
||||
return end !== undefined ? `bytes=${offset}-${end}` : `bytes=${offset}-`
|
||||
}
|
||||
}
|
||||
39
dbal/development/src/blob/providers/s3/client.ts
Normal file
39
dbal/development/src/blob/providers/s3/client.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BlobStorageConfig } from '../../blob-storage'
|
||||
|
||||
export interface S3Context {
|
||||
bucket: string
|
||||
s3Client: any
|
||||
}
|
||||
|
||||
export async function createS3Context(config: BlobStorageConfig): Promise<S3Context> {
|
||||
if (!config.s3) {
|
||||
throw new Error('S3 configuration required')
|
||||
}
|
||||
|
||||
const { bucket, ...s3Config } = config.s3
|
||||
|
||||
try {
|
||||
// @ts-ignore - optional dependency
|
||||
const s3Module = await import('@aws-sdk/client-s3').catch(() => null)
|
||||
if (!s3Module) {
|
||||
throw new Error('@aws-sdk/client-s3 is not installed. Install it with: npm install @aws-sdk/client-s3')
|
||||
}
|
||||
|
||||
const { S3Client } = s3Module
|
||||
|
||||
return {
|
||||
bucket,
|
||||
s3Client: new S3Client({
|
||||
region: s3Config.region,
|
||||
credentials: s3Config.accessKeyId && s3Config.secretAccessKey ? {
|
||||
accessKeyId: s3Config.accessKeyId,
|
||||
secretAccessKey: s3Config.secretAccessKey,
|
||||
} : undefined,
|
||||
endpoint: s3Config.endpoint,
|
||||
forcePathStyle: s3Config.forcePathStyle,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('AWS SDK @aws-sdk/client-s3 not installed. Install with: npm install @aws-sdk/client-s3')
|
||||
}
|
||||
}
|
||||
114
dbal/development/src/blob/providers/s3/index.ts
Normal file
114
dbal/development/src/blob/providers/s3/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
BlobStorage,
|
||||
BlobMetadata,
|
||||
BlobListResult,
|
||||
UploadOptions,
|
||||
DownloadOptions,
|
||||
BlobListOptions,
|
||||
BlobStorageConfig,
|
||||
} from '../../blob-storage'
|
||||
import { DBALError } from '../../core/foundation/errors'
|
||||
import type { S3Context } from './client'
|
||||
import { createS3Context } from './client'
|
||||
import { downloadBuffer, downloadStream } from './operations/downloads'
|
||||
import { listBlobs, sumSizes, countObjects } from './operations/listing'
|
||||
import { getMetadata, generatePresignedUrl } from './operations/metadata'
|
||||
import { uploadBuffer, uploadStream } from './operations/uploads'
|
||||
import { copyObject, deleteObject } from './operations/maintenance'
|
||||
|
||||
export class S3Storage implements BlobStorage {
|
||||
private contextPromise: Promise<S3Context>
|
||||
|
||||
constructor(config: BlobStorageConfig) {
|
||||
this.contextPromise = createS3Context(config)
|
||||
}
|
||||
|
||||
private async context(): Promise<S3Context> {
|
||||
return this.contextPromise
|
||||
}
|
||||
|
||||
async upload(
|
||||
key: string,
|
||||
data: Buffer | Uint8Array,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
const context = await this.context()
|
||||
return uploadBuffer(context, key, data, options)
|
||||
}
|
||||
|
||||
async uploadStream(
|
||||
key: string,
|
||||
stream: ReadableStream | NodeJS.ReadableStream,
|
||||
size: number,
|
||||
options: UploadOptions = {}
|
||||
): Promise<BlobMetadata> {
|
||||
const context = await this.context()
|
||||
return uploadStream(context, key, stream, size, options)
|
||||
}
|
||||
|
||||
async download(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<Buffer> {
|
||||
const context = await this.context()
|
||||
return downloadBuffer(context, key, options)
|
||||
}
|
||||
|
||||
async downloadStream(
|
||||
key: string,
|
||||
options: DownloadOptions = {}
|
||||
): Promise<ReadableStream | NodeJS.ReadableStream> {
|
||||
const context = await this.context()
|
||||
return downloadStream(context, key, options)
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const context = await this.context()
|
||||
return deleteObject(context, key)
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getMetadata(key)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 404) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(key: string): Promise<BlobMetadata> {
|
||||
const context = await this.context()
|
||||
return getMetadata(context, key)
|
||||
}
|
||||
|
||||
async list(options: BlobListOptions = {}): Promise<BlobListResult> {
|
||||
const context = await this.context()
|
||||
return listBlobs(context, options)
|
||||
}
|
||||
|
||||
async generatePresignedUrl(
|
||||
key: string,
|
||||
expirationSeconds: number = 3600
|
||||
): Promise<string> {
|
||||
const context = await this.context()
|
||||
return generatePresignedUrl(context, key, expirationSeconds)
|
||||
}
|
||||
|
||||
async copy(sourceKey: string, destKey: string): Promise<BlobMetadata> {
|
||||
const context = await this.context()
|
||||
return copyObject(context, sourceKey, destKey)
|
||||
}
|
||||
|
||||
async getTotalSize(): Promise<number> {
|
||||
const context = await this.context()
|
||||
return sumSizes(context)
|
||||
}
|
||||
|
||||
async getObjectCount(): Promise<number> {
|
||||
const context = await this.context()
|
||||
return countObjects(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { DownloadOptions } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import { buildRangeHeader } from '../range'
|
||||
import type { S3Context } from '../client'
|
||||
|
||||
export async function downloadBuffer(
|
||||
context: S3Context,
|
||||
key: string,
|
||||
options: DownloadOptions
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
Range: buildRangeHeader(options),
|
||||
})
|
||||
|
||||
const response = await context.s3Client.send(command)
|
||||
|
||||
const chunks: Uint8Array[] = []
|
||||
for await (const chunk of response.Body as any) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks)
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 download failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadStream(
|
||||
context: S3Context,
|
||||
key: string,
|
||||
options: DownloadOptions
|
||||
): Promise<ReadableStream | NodeJS.ReadableStream> {
|
||||
try {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
Range: buildRangeHeader(options),
|
||||
})
|
||||
|
||||
const response = await context.s3Client.send(command)
|
||||
return response.Body as any
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 download stream failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
71
dbal/development/src/blob/providers/s3/operations/listing.ts
Normal file
71
dbal/development/src/blob/providers/s3/operations/listing.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { BlobListOptions, BlobListResult, BlobMetadata } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { S3Context } from '../client'
|
||||
|
||||
export async function listBlobs(
|
||||
context: S3Context,
|
||||
options: BlobListOptions
|
||||
): Promise<BlobListResult> {
|
||||
try {
|
||||
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: context.bucket,
|
||||
Prefix: options.prefix,
|
||||
ContinuationToken: options.continuationToken,
|
||||
MaxKeys: options.maxKeys || 1000,
|
||||
})
|
||||
|
||||
const response = await context.s3Client.send(command)
|
||||
|
||||
const items: BlobMetadata[] = (response.Contents || []).map(obj => ({
|
||||
key: obj.Key || '',
|
||||
size: obj.Size || 0,
|
||||
contentType: 'application/octet-stream',
|
||||
etag: obj.ETag || '',
|
||||
lastModified: obj.LastModified || new Date(),
|
||||
}))
|
||||
|
||||
return {
|
||||
items,
|
||||
nextToken: response.NextContinuationToken,
|
||||
isTruncated: response.IsTruncated || false,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 list failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sumSizes(context: S3Context): Promise<number> {
|
||||
const result = await listBlobs(context, { maxKeys: 1000 })
|
||||
let total = result.items.reduce((sum, item) => sum + item.size, 0)
|
||||
|
||||
let nextToken = result.nextToken
|
||||
while (nextToken) {
|
||||
const pageResult = await listBlobs(context, {
|
||||
maxKeys: 1000,
|
||||
continuationToken: nextToken
|
||||
})
|
||||
total += pageResult.items.reduce((sum, item) => sum + item.size, 0)
|
||||
nextToken = pageResult.nextToken
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
export async function countObjects(context: S3Context): Promise<number> {
|
||||
const result = await listBlobs(context, { maxKeys: 1000 })
|
||||
let count = result.items.length
|
||||
|
||||
let nextToken = result.nextToken
|
||||
while (nextToken) {
|
||||
const pageResult = await listBlobs(context, {
|
||||
maxKeys: 1000,
|
||||
continuationToken: nextToken
|
||||
})
|
||||
count += pageResult.items.length
|
||||
nextToken = pageResult.nextToken
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BlobMetadata } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { S3Context } from '../client'
|
||||
import { getMetadata } from './metadata'
|
||||
|
||||
export async function deleteObject(
|
||||
context: S3Context,
|
||||
key: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
await context.s3Client.send(command)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 delete failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyObject(
|
||||
context: S3Context,
|
||||
sourceKey: string,
|
||||
destKey: string
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { CopyObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
CopySource: `${context.bucket}/${sourceKey}`,
|
||||
Key: destKey,
|
||||
})
|
||||
|
||||
await context.s3Client.send(command)
|
||||
|
||||
return await getMetadata(context, destKey)
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw DBALError.notFound(`Source blob not found: ${sourceKey}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 copy failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BlobMetadata } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { S3Context } from '../client'
|
||||
|
||||
export async function getMetadata(
|
||||
context: S3Context,
|
||||
key: string
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
const response = await context.s3Client.send(command)
|
||||
|
||||
return {
|
||||
key,
|
||||
size: response.ContentLength || 0,
|
||||
contentType: response.ContentType || 'application/octet-stream',
|
||||
etag: response.ETag || '',
|
||||
lastModified: response.LastModified || new Date(),
|
||||
customMetadata: response.Metadata,
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NotFound') {
|
||||
throw DBALError.notFound(`Blob not found: ${key}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 head object failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePresignedUrl(
|
||||
context: S3Context,
|
||||
key: string,
|
||||
expirationSeconds: number
|
||||
): Promise<string> {
|
||||
try {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
return await getSignedUrl(context.s3Client, command, {
|
||||
expiresIn: expirationSeconds,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 presigned URL generation failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
74
dbal/development/src/blob/providers/s3/operations/uploads.ts
Normal file
74
dbal/development/src/blob/providers/s3/operations/uploads.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { BlobMetadata, UploadOptions } from '../../../blob-storage'
|
||||
import { DBALError } from '../../../core/foundation/errors'
|
||||
import type { S3Context } from '../client'
|
||||
|
||||
export async function uploadBuffer(
|
||||
context: S3Context,
|
||||
key: string,
|
||||
data: Buffer | Uint8Array,
|
||||
options: UploadOptions
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: options.contentType,
|
||||
Metadata: options.metadata,
|
||||
})
|
||||
|
||||
const response = await context.s3Client.send(command)
|
||||
|
||||
return {
|
||||
key,
|
||||
size: data.length,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag: response.ETag || '',
|
||||
lastModified: new Date(),
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchBucket') {
|
||||
throw DBALError.notFound(`Bucket not found: ${context.bucket}`)
|
||||
}
|
||||
throw DBALError.internal(`S3 upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadStream(
|
||||
context: S3Context,
|
||||
key: string,
|
||||
stream: ReadableStream | NodeJS.ReadableStream,
|
||||
size: number,
|
||||
options: UploadOptions
|
||||
): Promise<BlobMetadata> {
|
||||
try {
|
||||
const { Upload } = await import('@aws-sdk/lib-storage')
|
||||
|
||||
const upload = new Upload({
|
||||
client: context.s3Client,
|
||||
params: {
|
||||
Bucket: context.bucket,
|
||||
Key: key,
|
||||
Body: stream as any,
|
||||
ContentType: options.contentType,
|
||||
Metadata: options.metadata,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await upload.done()
|
||||
|
||||
return {
|
||||
key,
|
||||
size,
|
||||
contentType: options.contentType || 'application/octet-stream',
|
||||
etag: response.ETag || '',
|
||||
lastModified: new Date(),
|
||||
customMetadata: options.metadata,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw DBALError.internal(`S3 stream upload failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
12
dbal/development/src/blob/providers/s3/range.ts
Normal file
12
dbal/development/src/blob/providers/s3/range.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { DownloadOptions } from '../../blob-storage'
|
||||
|
||||
export function buildRangeHeader(options: DownloadOptions): string | undefined {
|
||||
if (options.offset === undefined && options.length === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const offset = options.offset || 0
|
||||
const end = options.length !== undefined ? offset + options.length - 1 : undefined
|
||||
|
||||
return end !== undefined ? `bytes=${offset}-${end}` : `bytes=${offset}-`
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { DBALConfig } from '../../runtime/config'
|
||||
import type { DBALAdapter } from '../../adapters/adapter'
|
||||
import { DBALError } from '../foundation/errors'
|
||||
import { PrismaAdapter, PostgresAdapter, MySQLAdapter } from '../../adapters/prisma-adapter'
|
||||
import { PrismaAdapter, PostgresAdapter, MySQLAdapter } from '../../adapters/prisma'
|
||||
import { ACLAdapter } from '../../adapters/acl-adapter'
|
||||
import { WebSocketBridge } from '../../bridges/websocket-bridge'
|
||||
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* Key-Value Store with Multi-Tenant Support
|
||||
*
|
||||
* Stores primitive types (string, number, boolean) and complex types (objects, arrays)
|
||||
* with tenant isolation, access control, and quota management.
|
||||
*/
|
||||
|
||||
import { TenantContext } from './tenant-context'
|
||||
import { DBALError } from './errors'
|
||||
|
||||
export type StorableValue = string | number | boolean | null | object | Array<any>
|
||||
|
||||
export interface KVStoreEntry {
|
||||
key: string
|
||||
value: StorableValue
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'
|
||||
sizeBytes: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
export interface KVListOptions {
|
||||
prefix?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface KVListResult {
|
||||
entries: KVStoreEntry[]
|
||||
nextCursor?: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface KVStore {
|
||||
// Basic operations
|
||||
get(key: string, context: TenantContext): Promise<StorableValue | null>
|
||||
set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void>
|
||||
delete(key: string, context: TenantContext): Promise<boolean>
|
||||
exists(key: string, context: TenantContext): Promise<boolean>
|
||||
|
||||
// List operations
|
||||
listAdd(key: string, items: any[], context: TenantContext): Promise<number>
|
||||
listGet(key: string, context: TenantContext, start?: number, end?: number): Promise<any[]>
|
||||
listRemove(key: string, value: any, context: TenantContext): Promise<number>
|
||||
listLength(key: string, context: TenantContext): Promise<number>
|
||||
listClear(key: string, context: TenantContext): Promise<void>
|
||||
|
||||
// Batch operations
|
||||
mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>>
|
||||
mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void>
|
||||
|
||||
// Query operations
|
||||
list(options: KVListOptions, context: TenantContext): Promise<KVListResult>
|
||||
count(prefix: string, context: TenantContext): Promise<number>
|
||||
|
||||
// Utility
|
||||
clear(context: TenantContext): Promise<number>
|
||||
}
|
||||
|
||||
export class InMemoryKVStore implements KVStore {
|
||||
private data = new Map<string, KVStoreEntry>()
|
||||
|
||||
private getScopedKey(key: string, context: TenantContext): string {
|
||||
return `${context.namespace}${key}`
|
||||
}
|
||||
|
||||
private calculateSize(value: StorableValue): number {
|
||||
if (value === null || value === undefined) return 0
|
||||
if (typeof value === 'string') return value.length * 2 // UTF-16
|
||||
if (typeof value === 'number') return 8
|
||||
if (typeof value === 'boolean') return 1
|
||||
return JSON.stringify(value).length * 2
|
||||
}
|
||||
|
||||
private getValueType(value: StorableValue): KVStoreEntry['type'] {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
return typeof value as 'string' | 'number' | 'boolean' | 'object'
|
||||
}
|
||||
|
||||
async get(key: string, context: TenantContext): Promise<StorableValue | null> {
|
||||
if (!context.canRead('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot read key-value data')
|
||||
}
|
||||
|
||||
const scopedKey = this.getScopedKey(key, context)
|
||||
const entry = this.data.get(scopedKey)
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
// Check expiration
|
||||
if (entry.expiresAt && entry.expiresAt < new Date()) {
|
||||
this.data.delete(scopedKey)
|
||||
return null
|
||||
}
|
||||
|
||||
return entry.value
|
||||
}
|
||||
|
||||
async set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void> {
|
||||
if (!context.canWrite('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot write key-value data')
|
||||
}
|
||||
|
||||
const scopedKey = this.getScopedKey(key, context)
|
||||
const sizeBytes = this.calculateSize(value)
|
||||
|
||||
// Check quota
|
||||
const existing = this.data.get(scopedKey)
|
||||
const sizeDelta = existing ? sizeBytes - existing.sizeBytes : sizeBytes
|
||||
|
||||
if (sizeDelta > 0 && context.quota.maxDataSizeBytes) {
|
||||
if (context.quota.currentDataSizeBytes + sizeDelta > context.quota.maxDataSizeBytes) {
|
||||
throw DBALError.forbidden('Quota exceeded: maximum data size reached')
|
||||
}
|
||||
}
|
||||
|
||||
if (!existing && !context.canCreateRecord()) {
|
||||
throw DBALError.forbidden('Quota exceeded: maximum record count reached')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const entry: KVStoreEntry = {
|
||||
key,
|
||||
value,
|
||||
type: this.getValueType(value),
|
||||
sizeBytes,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
expiresAt: ttl ? new Date(now.getTime() + ttl * 1000) : undefined
|
||||
}
|
||||
|
||||
this.data.set(scopedKey, entry)
|
||||
|
||||
// Update quota (would normally be done by TenantManager)
|
||||
if (sizeDelta > 0) {
|
||||
context.quota.currentDataSizeBytes += sizeDelta
|
||||
}
|
||||
if (!existing) {
|
||||
context.quota.currentRecords++
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string, context: TenantContext): Promise<boolean> {
|
||||
if (!context.canDelete('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
|
||||
}
|
||||
|
||||
const scopedKey = this.getScopedKey(key, context)
|
||||
const entry = this.data.get(scopedKey)
|
||||
|
||||
if (!entry) return false
|
||||
|
||||
this.data.delete(scopedKey)
|
||||
|
||||
// Update quota
|
||||
context.quota.currentDataSizeBytes -= entry.sizeBytes
|
||||
context.quota.currentRecords--
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async exists(key: string, context: TenantContext): Promise<boolean> {
|
||||
if (!context.canRead('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot read key-value data')
|
||||
}
|
||||
|
||||
const value = await this.get(key, context)
|
||||
return value !== null
|
||||
}
|
||||
|
||||
// List operations
|
||||
async listAdd(key: string, items: any[], context: TenantContext): Promise<number> {
|
||||
if (!context.canWrite('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot write key-value data')
|
||||
}
|
||||
|
||||
if (!context.canAddToList(items.length)) {
|
||||
throw DBALError.forbidden('Quota exceeded: list length limit reached')
|
||||
}
|
||||
|
||||
const existing = await this.get(key, context)
|
||||
const list = Array.isArray(existing) ? existing : []
|
||||
list.push(...items)
|
||||
|
||||
await this.set(key, list, context)
|
||||
return list.length
|
||||
}
|
||||
|
||||
async listGet(key: string, context: TenantContext, start: number = 0, end?: number): Promise<any[]> {
|
||||
const value = await this.get(key, context)
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
if (end === undefined) {
|
||||
return value.slice(start)
|
||||
}
|
||||
return value.slice(start, end)
|
||||
}
|
||||
|
||||
async listRemove(key: string, valueToRemove: any, context: TenantContext): Promise<number> {
|
||||
if (!context.canWrite('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot write key-value data')
|
||||
}
|
||||
|
||||
const existing = await this.get(key, context)
|
||||
if (!Array.isArray(existing)) return 0
|
||||
|
||||
const filtered = existing.filter(item => !this.deepEquals(item, valueToRemove))
|
||||
const removed = existing.length - filtered.length
|
||||
|
||||
if (removed > 0) {
|
||||
await this.set(key, filtered, context)
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
async listLength(key: string, context: TenantContext): Promise<number> {
|
||||
const value = await this.get(key, context)
|
||||
return Array.isArray(value) ? value.length : 0
|
||||
}
|
||||
|
||||
async listClear(key: string, context: TenantContext): Promise<void> {
|
||||
await this.set(key, [], context)
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
async mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>> {
|
||||
const result = new Map<string, StorableValue | null>()
|
||||
|
||||
for (const key of keys) {
|
||||
const value = await this.get(key, context)
|
||||
result.set(key, value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void> {
|
||||
for (const [key, value] of entries) {
|
||||
await this.set(key, value, context)
|
||||
}
|
||||
}
|
||||
|
||||
// Query operations
|
||||
async list(options: KVListOptions, context: TenantContext): Promise<KVListResult> {
|
||||
if (!context.canRead('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot read key-value data')
|
||||
}
|
||||
|
||||
const prefix = options.prefix || ''
|
||||
const limit = options.limit || 100
|
||||
const scopedPrefix = this.getScopedKey(prefix, context)
|
||||
|
||||
const entries: KVStoreEntry[] = []
|
||||
|
||||
for (const [scopedKey, entry] of this.data) {
|
||||
if (scopedKey.startsWith(scopedPrefix)) {
|
||||
// Skip expired entries
|
||||
if (entry.expiresAt && entry.expiresAt < new Date()) {
|
||||
continue
|
||||
}
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= limit) break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
hasMore: false, // Simplified for in-memory implementation
|
||||
nextCursor: undefined
|
||||
}
|
||||
}
|
||||
|
||||
async count(prefix: string, context: TenantContext): Promise<number> {
|
||||
const result = await this.list({ prefix, limit: Number.MAX_SAFE_INTEGER }, context)
|
||||
return result.entries.length
|
||||
}
|
||||
|
||||
async clear(context: TenantContext): Promise<number> {
|
||||
if (!context.canDelete('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
|
||||
}
|
||||
|
||||
const scopedPrefix = this.getScopedKey('', context)
|
||||
let count = 0
|
||||
|
||||
for (const [scopedKey] of this.data) {
|
||||
if (scopedKey.startsWith(scopedPrefix)) {
|
||||
this.data.delete(scopedKey)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// Reset quota
|
||||
context.quota.currentDataSizeBytes = 0
|
||||
context.quota.currentRecords = 0
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
private deepEquals(a: any, b: any): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
}
|
||||
67
dbal/development/src/core/kv/index.ts
Normal file
67
dbal/development/src/core/kv/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { TenantContext } from '../foundation/tenant-context'
|
||||
import type { KVListOptions, KVListResult, KVStore, KVStoreState, StorableValue } from './types'
|
||||
import { clear, count, listEntries, mget, mset } from './operations/batch'
|
||||
import { getValue, exists, listGet, listLength } from './operations/read'
|
||||
import { deleteValue, listAdd, listClear, listRemove, setValue } from './operations/write'
|
||||
|
||||
export class InMemoryKVStore implements KVStore {
|
||||
private state: KVStoreState = { data: new Map() }
|
||||
|
||||
get(key: string, context: TenantContext): Promise<StorableValue | null> {
|
||||
return getValue(this.state, key, context)
|
||||
}
|
||||
|
||||
set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void> {
|
||||
return setValue(this.state, key, value, context, ttl)
|
||||
}
|
||||
|
||||
delete(key: string, context: TenantContext): Promise<boolean> {
|
||||
return deleteValue(this.state, key, context)
|
||||
}
|
||||
|
||||
exists(key: string, context: TenantContext): Promise<boolean> {
|
||||
return exists(this.state, key, context)
|
||||
}
|
||||
|
||||
listAdd(key: string, items: any[], context: TenantContext): Promise<number> {
|
||||
return listAdd(this.state, key, items, context)
|
||||
}
|
||||
|
||||
listGet(key: string, context: TenantContext, start?: number, end?: number): Promise<any[]> {
|
||||
return listGet(this.state, key, context, start, end)
|
||||
}
|
||||
|
||||
listRemove(key: string, value: any, context: TenantContext): Promise<number> {
|
||||
return listRemove(this.state, key, value, context)
|
||||
}
|
||||
|
||||
listLength(key: string, context: TenantContext): Promise<number> {
|
||||
return listLength(this.state, key, context)
|
||||
}
|
||||
|
||||
listClear(key: string, context: TenantContext): Promise<void> {
|
||||
return listClear(this.state, key, context)
|
||||
}
|
||||
|
||||
mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>> {
|
||||
return mget(this.state, keys, context)
|
||||
}
|
||||
|
||||
mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void> {
|
||||
return mset(this.state, entries, context)
|
||||
}
|
||||
|
||||
list(options: KVListOptions, context: TenantContext): Promise<KVListResult> {
|
||||
return listEntries(this.state, options, context)
|
||||
}
|
||||
|
||||
count(prefix: string, context: TenantContext): Promise<number> {
|
||||
return count(prefix, this.state, context)
|
||||
}
|
||||
|
||||
clear(context: TenantContext): Promise<number> {
|
||||
return clear(this.state, context)
|
||||
}
|
||||
}
|
||||
|
||||
export type { KVStoreEntry, KVListOptions, KVListResult, StorableValue } from './types'
|
||||
95
dbal/development/src/core/kv/operations/batch.ts
Normal file
95
dbal/development/src/core/kv/operations/batch.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { DBALError } from '../../foundation/errors'
|
||||
import type { TenantContext } from '../../foundation/tenant-context'
|
||||
import { scopedKey, getEntry } from '../scoping'
|
||||
import type { KVListOptions, KVListResult, KVStoreState, StorableValue } from '../types'
|
||||
import { setValue } from './write'
|
||||
|
||||
export async function mget(
|
||||
state: KVStoreState,
|
||||
keys: string[],
|
||||
context: TenantContext
|
||||
): Promise<Map<string, StorableValue | null>> {
|
||||
const result = new Map<string, StorableValue | null>()
|
||||
|
||||
for (const key of keys) {
|
||||
const scoped = scopedKey(key, context)
|
||||
const entry = getEntry(state, scoped)
|
||||
result.set(key, entry?.value ?? null)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function mset(
|
||||
state: KVStoreState,
|
||||
entries: Map<string, StorableValue>,
|
||||
context: TenantContext
|
||||
): Promise<void> {
|
||||
for (const [key, value] of entries) {
|
||||
await setValue(state, key, value, context)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEntries(
|
||||
state: KVStoreState,
|
||||
options: KVListOptions,
|
||||
context: TenantContext
|
||||
): Promise<KVListResult> {
|
||||
if (!context.canRead('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot read key-value data')
|
||||
}
|
||||
|
||||
const prefix = options.prefix || ''
|
||||
const limit = options.limit || 100
|
||||
const scopedPrefix = scopedKey(prefix, context)
|
||||
|
||||
const entries: KVListEntry[] = []
|
||||
|
||||
for (const [scoped, entry] of state.data) {
|
||||
if (scoped.startsWith(scopedPrefix)) {
|
||||
if (entry.expiresAt && entry.expiresAt < new Date()) {
|
||||
continue
|
||||
}
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= limit) break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
hasMore: false,
|
||||
nextCursor: undefined
|
||||
}
|
||||
}
|
||||
|
||||
type KVListEntry = KVListResult['entries'][number]
|
||||
|
||||
export async function count(prefix: string, state: KVStoreState, context: TenantContext): Promise<number> {
|
||||
const result = await listEntries(state, { prefix, limit: Number.MAX_SAFE_INTEGER }, context)
|
||||
return result.entries.length
|
||||
}
|
||||
|
||||
export async function clear(
|
||||
state: KVStoreState,
|
||||
context: TenantContext
|
||||
): Promise<number> {
|
||||
if (!context.canDelete('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
|
||||
}
|
||||
|
||||
const scopedPrefix = scopedKey('', context)
|
||||
let removed = 0
|
||||
|
||||
for (const [scoped] of state.data) {
|
||||
if (scoped.startsWith(scopedPrefix)) {
|
||||
state.data.delete(scoped)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
context.quota.currentDataSizeBytes = 0
|
||||
context.quota.currentRecords = 0
|
||||
|
||||
return removed
|
||||
}
|
||||
53
dbal/development/src/core/kv/operations/read.ts
Normal file
53
dbal/development/src/core/kv/operations/read.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DBALError } from '../../foundation/errors'
|
||||
import type { TenantContext } from '../../foundation/tenant-context'
|
||||
import { getEntry, scopedKey } from '../scoping'
|
||||
import type { KVStoreState, StorableValue } from '../types'
|
||||
|
||||
export async function getValue(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext
|
||||
): Promise<StorableValue | null> {
|
||||
if (!context.canRead('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot read key-value data')
|
||||
}
|
||||
|
||||
const scoped = scopedKey(key, context)
|
||||
const entry = getEntry(state, scoped)
|
||||
|
||||
return entry?.value ?? null
|
||||
}
|
||||
|
||||
export async function exists(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext
|
||||
): Promise<boolean> {
|
||||
const value = await getValue(state, key, context)
|
||||
return value !== null
|
||||
}
|
||||
|
||||
export async function listGet(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext,
|
||||
start: number = 0,
|
||||
end?: number
|
||||
): Promise<any[]> {
|
||||
const value = await getValue(state, key, context)
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
if (end === undefined) {
|
||||
return value.slice(start)
|
||||
}
|
||||
return value.slice(start, end)
|
||||
}
|
||||
|
||||
export async function listLength(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext
|
||||
): Promise<number> {
|
||||
const value = await getValue(state, key, context)
|
||||
return Array.isArray(value) ? value.length : 0
|
||||
}
|
||||
143
dbal/development/src/core/kv/operations/write.ts
Normal file
143
dbal/development/src/core/kv/operations/write.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { DBALError } from '../../foundation/errors'
|
||||
import type { TenantContext } from '../../foundation/tenant-context'
|
||||
import { calculateSize, deepEquals, scopedKey, valueType } from '../scoping'
|
||||
import type { KVStoreEntry, KVStoreState, StorableValue } from '../types'
|
||||
|
||||
export async function setValue(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
value: StorableValue,
|
||||
context: TenantContext,
|
||||
ttl?: number
|
||||
): Promise<void> {
|
||||
if (!context.canWrite('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot write key-value data')
|
||||
}
|
||||
|
||||
const scoped = scopedKey(key, context)
|
||||
const sizeBytes = calculateSize(value)
|
||||
const existing = state.data.get(scoped)
|
||||
const sizeDelta = existing ? sizeBytes - existing.sizeBytes : sizeBytes
|
||||
|
||||
if (sizeDelta > 0 && context.quota.maxDataSizeBytes) {
|
||||
if (context.quota.currentDataSizeBytes + sizeDelta > context.quota.maxDataSizeBytes) {
|
||||
throw DBALError.forbidden('Quota exceeded: maximum data size reached')
|
||||
}
|
||||
}
|
||||
|
||||
if (!existing && !context.canCreateRecord()) {
|
||||
throw DBALError.forbidden('Quota exceeded: maximum record count reached')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const entry: KVStoreEntry = {
|
||||
key,
|
||||
value,
|
||||
type: valueType(value),
|
||||
sizeBytes,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
expiresAt: ttl ? new Date(now.getTime() + ttl * 1000) : undefined
|
||||
}
|
||||
|
||||
state.data.set(scoped, entry)
|
||||
|
||||
if (sizeDelta > 0) {
|
||||
context.quota.currentDataSizeBytes += sizeDelta
|
||||
}
|
||||
if (!existing) {
|
||||
context.quota.currentRecords++
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteValue(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext
|
||||
): Promise<boolean> {
|
||||
if (!context.canDelete('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot delete key-value data')
|
||||
}
|
||||
|
||||
const scoped = scopedKey(key, context)
|
||||
const existing = state.data.get(scoped)
|
||||
|
||||
if (!existing) return false
|
||||
|
||||
state.data.delete(scoped)
|
||||
context.quota.currentDataSizeBytes -= existing.sizeBytes
|
||||
context.quota.currentRecords--
|
||||
return true
|
||||
}
|
||||
|
||||
export async function listAdd(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
items: any[],
|
||||
context: TenantContext
|
||||
): Promise<number> {
|
||||
if (!context.canWrite('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot write key-value data')
|
||||
}
|
||||
|
||||
if (!context.canAddToList(items.length)) {
|
||||
throw DBALError.forbidden('Quota exceeded: list length limit reached')
|
||||
}
|
||||
|
||||
const existing = await getValueForWrite(state, key, context)
|
||||
const list = Array.isArray(existing) ? existing : []
|
||||
list.push(...items)
|
||||
|
||||
await setValue(state, key, list, context)
|
||||
return list.length
|
||||
}
|
||||
|
||||
export async function listRemove(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
valueToRemove: any,
|
||||
context: TenantContext
|
||||
): Promise<number> {
|
||||
if (!context.canWrite('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot write key-value data')
|
||||
}
|
||||
|
||||
const existing = await getValueForWrite(state, key, context)
|
||||
if (!Array.isArray(existing)) return 0
|
||||
|
||||
const filtered = existing.filter(item => !deepEquals(item, valueToRemove))
|
||||
const removed = existing.length - filtered.length
|
||||
|
||||
if (removed > 0) {
|
||||
await setValue(state, key, filtered, context)
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
export async function listClear(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext
|
||||
): Promise<void> {
|
||||
await setValue(state, key, [], context)
|
||||
}
|
||||
|
||||
async function getValueForWrite(
|
||||
state: KVStoreState,
|
||||
key: string,
|
||||
context: TenantContext
|
||||
): Promise<StorableValue | null> {
|
||||
if (!context.canRead('kv')) {
|
||||
throw DBALError.forbidden('Permission denied: cannot read key-value data')
|
||||
}
|
||||
|
||||
const scoped = scopedKey(key, context)
|
||||
const entry = state.data.get(scoped)
|
||||
if (!entry) return null
|
||||
if (entry.expiresAt && entry.expiresAt < new Date()) {
|
||||
state.data.delete(scoped)
|
||||
return null
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
38
dbal/development/src/core/kv/scoping.ts
Normal file
38
dbal/development/src/core/kv/scoping.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { KVStoreEntry, KVStoreState, StorableValue } from './types'
|
||||
import type { TenantContext } from '../foundation/tenant-context'
|
||||
|
||||
export function scopedKey(key: string, context: TenantContext): string {
|
||||
return `${context.namespace}${key}`
|
||||
}
|
||||
|
||||
export function calculateSize(value: StorableValue): number {
|
||||
if (value === null || value === undefined) return 0
|
||||
if (typeof value === 'string') return value.length * 2
|
||||
if (typeof value === 'number') return 8
|
||||
if (typeof value === 'boolean') return 1
|
||||
return JSON.stringify(value).length * 2
|
||||
}
|
||||
|
||||
export function valueType(value: StorableValue): KVStoreEntry['type'] {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
return typeof value as 'string' | 'number' | 'boolean' | 'object'
|
||||
}
|
||||
|
||||
export function isExpired(entry: KVStoreEntry): boolean {
|
||||
return Boolean(entry.expiresAt && entry.expiresAt < new Date())
|
||||
}
|
||||
|
||||
export function deepEquals(a: any, b: any): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
export function getEntry(state: KVStoreState, scoped: string): KVStoreEntry | undefined {
|
||||
const entry = state.data.get(scoped)
|
||||
if (!entry) return undefined
|
||||
if (isExpired(entry)) {
|
||||
state.data.delete(scoped)
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
46
dbal/development/src/core/kv/types.ts
Normal file
46
dbal/development/src/core/kv/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { TenantContext } from '../foundation/tenant-context'
|
||||
|
||||
export type StorableValue = string | number | boolean | null | object | Array<any>
|
||||
|
||||
export interface KVStoreEntry {
|
||||
key: string
|
||||
value: StorableValue
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'
|
||||
sizeBytes: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
export interface KVListOptions {
|
||||
prefix?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface KVListResult {
|
||||
entries: KVStoreEntry[]
|
||||
nextCursor?: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface KVStore {
|
||||
get(key: string, context: TenantContext): Promise<StorableValue | null>
|
||||
set(key: string, value: StorableValue, context: TenantContext, ttl?: number): Promise<void>
|
||||
delete(key: string, context: TenantContext): Promise<boolean>
|
||||
exists(key: string, context: TenantContext): Promise<boolean>
|
||||
listAdd(key: string, items: any[], context: TenantContext): Promise<number>
|
||||
listGet(key: string, context: TenantContext, start?: number, end?: number): Promise<any[]>
|
||||
listRemove(key: string, value: any, context: TenantContext): Promise<number>
|
||||
listLength(key: string, context: TenantContext): Promise<number>
|
||||
listClear(key: string, context: TenantContext): Promise<void>
|
||||
mget(keys: string[], context: TenantContext): Promise<Map<string, StorableValue | null>>
|
||||
mset(entries: Map<string, StorableValue>, context: TenantContext): Promise<void>
|
||||
list(options: KVListOptions, context: TenantContext): Promise<KVListResult>
|
||||
count(prefix: string, context: TenantContext): Promise<number>
|
||||
clear(context: TenantContext): Promise<number>
|
||||
}
|
||||
|
||||
export interface KVStoreState {
|
||||
data: Map<string, KVStoreEntry>
|
||||
}
|
||||
@@ -4,5 +4,5 @@ export type * from './core/foundation/types'
|
||||
export { DBALError, DBALErrorCode } from './core/foundation/errors'
|
||||
export * from './core/validation'
|
||||
export * from './core/foundation/tenant-context'
|
||||
export * from './core/foundation/kv-store'
|
||||
export * from './core/kv'
|
||||
export * from './blob'
|
||||
|
||||
10
frontends/nextjs/package-lock.json
generated
10
frontends/nextjs/package-lock.json
generated
@@ -5743,6 +5743,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jszip": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.1.tgz",
|
||||
"integrity": "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==",
|
||||
"deprecated": "This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jszip": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,681 +1 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { Plus, Trash, Play, CheckCircle, XCircle, FileCode, ArrowsOut, BookOpen, ShieldCheck } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
|
||||
import type { LuaExecutionResult } from '@/lib/lua-engine'
|
||||
import { getLuaExampleCode, getLuaExamplesList } from '@/lib/lua-examples'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui'
|
||||
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
|
||||
import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog'
|
||||
|
||||
interface LuaEditorProps {
|
||||
scripts: LuaScript[]
|
||||
onScriptsChange: (scripts: LuaScript[]) => void
|
||||
}
|
||||
|
||||
export function LuaEditor({ scripts, onScriptsChange }: LuaEditorProps) {
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(
|
||||
scripts.length > 0 ? scripts[0].id : null
|
||||
)
|
||||
const [testOutput, setTestOutput] = useState<LuaExecutionResult | null>(null)
|
||||
const [testInputs, setTestInputs] = useState<Record<string, any>>({})
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showSnippetLibrary, setShowSnippetLibrary] = useState(false)
|
||||
const [securityScanResult, setSecurityScanResult] = useState<SecurityScanResult | null>(null)
|
||||
const [showSecurityDialog, setShowSecurityDialog] = useState(false)
|
||||
const editorRef = useRef<any>(null)
|
||||
const monaco = useMonaco()
|
||||
|
||||
const currentScript = scripts.find(s => s.id === selectedScript)
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
monaco.languages.registerCompletionItemProvider('lua', {
|
||||
provideCompletionItems: (model, position) => {
|
||||
const word = model.getWordUntilPosition(position)
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
}
|
||||
|
||||
const suggestions: any[] = [
|
||||
{
|
||||
label: 'context.data',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.data',
|
||||
documentation: 'Access input parameters passed to the script',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'context.user',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.user',
|
||||
documentation: 'Current user information (username, role, etc.)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'context.kv',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.kv',
|
||||
documentation: 'Key-value storage interface',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'context.log',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'context.log(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Log a message to the output console',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'log',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'log(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Log a message (shortcut for context.log)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'print',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'print(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Print a message to output',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'return',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: 'return ${1:result}',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Return a value from the script',
|
||||
range
|
||||
},
|
||||
]
|
||||
|
||||
return { suggestions }
|
||||
}
|
||||
})
|
||||
|
||||
monaco.languages.setLanguageConfiguration('lua', {
|
||||
comments: {
|
||||
lineComment: '--',
|
||||
blockComment: ['--[[', ']]']
|
||||
},
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')']
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" }
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [monaco])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentScript) {
|
||||
const inputs: Record<string, any> = {}
|
||||
currentScript.parameters.forEach((param) => {
|
||||
inputs[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : ''
|
||||
})
|
||||
setTestInputs(inputs)
|
||||
}
|
||||
}, [selectedScript, currentScript?.parameters.length])
|
||||
|
||||
const handleAddScript = () => {
|
||||
const newScript: LuaScript = {
|
||||
id: `lua_${Date.now()}`,
|
||||
name: 'New Script',
|
||||
code: '-- Lua script example\n-- Access input parameters via context.data\n-- Use log() or print() to output messages\n\nlog("Script started")\n\nif context.data then\n log("Received data:", context.data)\nend\n\nlocal result = {\n success = true,\n message = "Script executed successfully"\n}\n\nreturn result',
|
||||
parameters: [],
|
||||
}
|
||||
onScriptsChange([...scripts, newScript])
|
||||
setSelectedScript(newScript.id)
|
||||
toast.success('Script created')
|
||||
}
|
||||
|
||||
const handleDeleteScript = (scriptId: string) => {
|
||||
onScriptsChange(scripts.filter(s => s.id !== scriptId))
|
||||
if (selectedScript === scriptId) {
|
||||
setSelectedScript(scripts.length > 1 ? scripts[0].id : null)
|
||||
}
|
||||
toast.success('Script deleted')
|
||||
}
|
||||
|
||||
const handleUpdateScript = (updates: Partial<LuaScript>) => {
|
||||
if (!currentScript) return
|
||||
|
||||
onScriptsChange(
|
||||
scripts.map(s => s.id === selectedScript ? { ...s, ...updates } : s)
|
||||
)
|
||||
}
|
||||
|
||||
const handleTestScript = async () => {
|
||||
if (!currentScript) return
|
||||
|
||||
const scanResult = securityScanner.scanLua(currentScript.code)
|
||||
setSecurityScanResult(scanResult)
|
||||
|
||||
if (scanResult.severity === 'critical' || scanResult.severity === 'high') {
|
||||
setShowSecurityDialog(true)
|
||||
toast.warning('Security issues detected in script')
|
||||
return
|
||||
}
|
||||
|
||||
if (scanResult.severity === 'medium' && scanResult.issues.length > 0) {
|
||||
toast.warning(`${scanResult.issues.length} security warning(s) detected`)
|
||||
}
|
||||
|
||||
setIsExecuting(true)
|
||||
setTestOutput(null)
|
||||
|
||||
try {
|
||||
const contextData: any = {}
|
||||
currentScript.parameters.forEach((param) => {
|
||||
contextData[param.name] = testInputs[param.name]
|
||||
})
|
||||
|
||||
const result = await executeLuaScriptWithProfile(currentScript.code, {
|
||||
data: contextData,
|
||||
user: { username: 'test_user', role: 'god' },
|
||||
log: (...args: any[]) => console.log('[Lua]', ...args)
|
||||
}, currentScript)
|
||||
|
||||
setTestOutput(result)
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Script executed successfully')
|
||||
} else {
|
||||
toast.error('Script execution failed')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Execution error: ' + (error instanceof Error ? error.message : String(error)))
|
||||
setTestOutput({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
logs: []
|
||||
})
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanCode = () => {
|
||||
if (!currentScript) return
|
||||
|
||||
const scanResult = securityScanner.scanLua(currentScript.code)
|
||||
setSecurityScanResult(scanResult)
|
||||
setShowSecurityDialog(true)
|
||||
|
||||
if (scanResult.safe) {
|
||||
toast.success('No security issues detected')
|
||||
} else {
|
||||
toast.warning(`${scanResult.issues.length} security issue(s) detected`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProceedWithExecution = () => {
|
||||
setShowSecurityDialog(false)
|
||||
if (!currentScript) return
|
||||
|
||||
setIsExecuting(true)
|
||||
setTestOutput(null)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const contextData: any = {}
|
||||
currentScript.parameters.forEach((param) => {
|
||||
contextData[param.name] = testInputs[param.name]
|
||||
})
|
||||
|
||||
const result = await executeLuaScriptWithProfile(currentScript.code, {
|
||||
data: contextData,
|
||||
user: { username: 'test_user', role: 'god' },
|
||||
log: (...args: any[]) => console.log('[Lua]', ...args)
|
||||
}, currentScript)
|
||||
|
||||
setTestOutput(result)
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Script executed successfully')
|
||||
} else {
|
||||
toast.error('Script execution failed')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Execution error: ' + (error instanceof Error ? error.message : String(error)))
|
||||
setTestOutput({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
logs: []
|
||||
})
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleAddParameter = () => {
|
||||
if (!currentScript) return
|
||||
|
||||
const newParam = { name: `param${currentScript.parameters.length + 1}`, type: 'string' }
|
||||
handleUpdateScript({
|
||||
parameters: [...currentScript.parameters, newParam],
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteParameter = (index: number) => {
|
||||
if (!currentScript) return
|
||||
|
||||
handleUpdateScript({
|
||||
parameters: currentScript.parameters.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateParameter = (index: number, updates: { name?: string; type?: string }) => {
|
||||
if (!currentScript) return
|
||||
|
||||
handleUpdateScript({
|
||||
parameters: currentScript.parameters.map((p, i) =>
|
||||
i === index ? { ...p, ...updates } : p
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const handleInsertSnippet = (code: string) => {
|
||||
if (!currentScript) return
|
||||
|
||||
if (editorRef.current) {
|
||||
const selection = editorRef.current.getSelection()
|
||||
if (selection) {
|
||||
editorRef.current.executeEdits('', [{
|
||||
range: selection,
|
||||
text: code,
|
||||
forceMoveMarkers: true
|
||||
}])
|
||||
editorRef.current.focus()
|
||||
} else {
|
||||
const currentCode = currentScript.code
|
||||
const newCode = currentCode ? currentCode + '\n\n' + code : code
|
||||
handleUpdateScript({ code: newCode })
|
||||
}
|
||||
} else {
|
||||
const currentCode = currentScript.code
|
||||
const newCode = currentCode ? currentCode + '\n\n' + code : code
|
||||
handleUpdateScript({ code: newCode })
|
||||
}
|
||||
|
||||
setShowSnippetLibrary(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6 h-full">
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Lua Scripts</CardTitle>
|
||||
<Button size="sm" onClick={handleAddScript}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>Custom logic scripts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{scripts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No scripts yet. Create one to start.
|
||||
</p>
|
||||
) : (
|
||||
scripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedScript === script.id
|
||||
? 'bg-accent border-accent-foreground'
|
||||
: 'hover:bg-muted border-border'
|
||||
}`}
|
||||
onClick={() => setSelectedScript(script.id)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm font-mono">{script.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{script.parameters.length} params
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteScript(script.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
{!currentScript ? (
|
||||
<CardContent className="flex items-center justify-center h-full min-h-[400px]">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Select or create a script to edit</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Edit Script: {currentScript.name}</CardTitle>
|
||||
<CardDescription>Write custom Lua logic</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleScanCode}>
|
||||
<ShieldCheck className="mr-2" size={16} />
|
||||
Security Scan
|
||||
</Button>
|
||||
<Button onClick={handleTestScript} disabled={isExecuting}>
|
||||
<Play className="mr-2" size={16} />
|
||||
{isExecuting ? 'Executing...' : 'Test Script'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Script Name</Label>
|
||||
<Input
|
||||
value={currentScript.name}
|
||||
onChange={(e) => handleUpdateScript({ name: e.target.value })}
|
||||
placeholder="validate_user"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Return Type</Label>
|
||||
<Input
|
||||
value={currentScript.returnType || ''}
|
||||
onChange={(e) => handleUpdateScript({ returnType: e.target.value })}
|
||||
placeholder="table, boolean, string..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={currentScript.description || ''}
|
||||
onChange={(e) => handleUpdateScript({ description: e.target.value })}
|
||||
placeholder="What this script does..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Parameters</Label>
|
||||
<Button size="sm" variant="outline" onClick={handleAddParameter}>
|
||||
<Plus className="mr-2" size={14} />
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{currentScript.parameters.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-3 border border-dashed rounded-lg">
|
||||
No parameters defined
|
||||
</p>
|
||||
) : (
|
||||
currentScript.parameters.map((param, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={param.name}
|
||||
onChange={(e) => handleUpdateParameter(index, { name: e.target.value })}
|
||||
placeholder="paramName"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={param.type}
|
||||
onChange={(e) => handleUpdateParameter(index, { type: e.target.value })}
|
||||
placeholder="string"
|
||||
className="w-32 text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteParameter(index)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentScript.parameters.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Test Input Values</Label>
|
||||
<div className="space-y-2">
|
||||
{currentScript.parameters.map((param) => (
|
||||
<div key={param.name} className="flex gap-2 items-center">
|
||||
<Label className="w-32 text-sm font-mono">{param.name}</Label>
|
||||
<Input
|
||||
value={testInputs[param.name] ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = param.type === 'number'
|
||||
? parseFloat(e.target.value) || 0
|
||||
: param.type === 'boolean'
|
||||
? e.target.value === 'true'
|
||||
: e.target.value
|
||||
setTestInputs({ ...testInputs, [param.name]: value })
|
||||
}}
|
||||
placeholder={`Enter ${param.type} value`}
|
||||
className="flex-1 text-sm"
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{param.type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Lua Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Sheet open={showSnippetLibrary} onOpenChange={setShowSnippetLibrary}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen size={16} className="mr-2" />
|
||||
Snippet Library
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Lua Snippet Library</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse and insert pre-built code templates
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
<LuaSnippetLibrary onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const exampleCode = getLuaExampleCode(value as any)
|
||||
handleUpdateScript({ code: exampleCode })
|
||||
toast.success('Example loaded')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<FileCode size={16} className="mr-2" />
|
||||
<SelectValue placeholder="Examples" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getLuaExamplesList().map((example) => (
|
||||
<SelectItem key={example.key} value={example.key}>
|
||||
<div>
|
||||
<div className="font-medium">{example.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{example.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
<ArrowsOut size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`border rounded-lg overflow-hidden ${isFullscreen ? 'fixed inset-4 z-50 bg-background' : ''}`}>
|
||||
<Editor
|
||||
height={isFullscreen ? 'calc(100vh - 8rem)' : '400px'}
|
||||
language="lua"
|
||||
value={currentScript.code}
|
||||
onChange={(value) => handleUpdateScript({ code: value || '' })}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
}}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: isFullscreen },
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: true,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
acceptSuggestionOnEnter: 'on',
|
||||
snippetSuggestions: 'inline',
|
||||
parameterHints: { enabled: true },
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Write Lua code. Access parameters via <code className="font-mono">context.data</code>. Use <code className="font-mono">log()</code> or <code className="font-mono">print()</code> for output. Press <code className="font-mono">Ctrl+Space</code> for autocomplete.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{testOutput && (
|
||||
<Card className={testOutput.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
{testOutput.success ? (
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-600" />
|
||||
)}
|
||||
<CardTitle className="text-sm">
|
||||
{testOutput.success ? 'Execution Successful' : 'Execution Failed'}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{testOutput.error && (
|
||||
<div>
|
||||
<Label className="text-xs text-red-600 mb-1">Error</Label>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap text-red-700 bg-red-100 p-2 rounded">
|
||||
{testOutput.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testOutput.logs.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs mb-1">Logs</Label>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded">
|
||||
{testOutput.logs.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testOutput.result !== null && testOutput.result !== undefined && (
|
||||
<div>
|
||||
<Label className="text-xs mb-1">Return Value</Label>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded">
|
||||
{JSON.stringify(testOutput.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-dashed">
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<p className="font-semibold text-foreground">Available in context:</p>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li><code className="font-mono">context.data</code> - Input data</li>
|
||||
<li><code className="font-mono">context.user</code> - Current user info</li>
|
||||
<li><code className="font-mono">context.kv</code> - Key-value storage</li>
|
||||
<li><code className="font-mono">context.log(msg)</code> - Logging function</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{securityScanResult && (
|
||||
<SecurityWarningDialog
|
||||
open={showSecurityDialog}
|
||||
onOpenChange={setShowSecurityDialog}
|
||||
scanResult={securityScanResult}
|
||||
onProceed={handleProceedWithExecution}
|
||||
onCancel={() => setShowSecurityDialog(false)}
|
||||
codeType="Lua script"
|
||||
showProceedButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { LuaEditor } from './lua-editor/LuaEditor'
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Play, ShieldCheck } from '@phosphor-icons/react'
|
||||
|
||||
interface LuaEditorToolbarProps {
|
||||
scriptName: string
|
||||
onScanCode: () => void
|
||||
onTestScript: () => void
|
||||
isExecuting: boolean
|
||||
}
|
||||
|
||||
export const LuaEditorToolbar = ({
|
||||
scriptName,
|
||||
onScanCode,
|
||||
onTestScript,
|
||||
isExecuting,
|
||||
}: LuaEditorToolbarProps) => (
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Edit Script: {scriptName}</CardTitle>
|
||||
<CardDescription>Write custom Lua logic</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onScanCode}>
|
||||
<ShieldCheck className="mr-2" size={16} />
|
||||
Security Scan
|
||||
</Button>
|
||||
<Button onClick={onTestScript} disabled={isExecuting}>
|
||||
<Play className="mr-2" size={16} />
|
||||
{isExecuting ? 'Executing...' : 'Test Script'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
|
||||
const createDefaultScript = (): LuaScript => ({
|
||||
id: `lua_${Date.now()}`,
|
||||
name: 'New Script',
|
||||
code: '-- Lua script example\n-- Access input parameters via context.data\n-- Use log() or print() to output messages\n\nlog("Script started")\n\nif context.data then\n log("Received data:", context.data)\nend\n\nlocal result = {\n success = true,\n message = "Script executed successfully"\n}\n\nreturn result',
|
||||
parameters: [],
|
||||
})
|
||||
|
||||
export const useLuaPersistence = (
|
||||
scripts: LuaScript[],
|
||||
onScriptsChange: (scripts: LuaScript[]) => void
|
||||
) => {
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(
|
||||
scripts.length > 0 ? scripts[0].id : null
|
||||
)
|
||||
|
||||
const currentScript = scripts.find((script) => script.id === selectedScript) || null
|
||||
|
||||
const handleAddScript = () => {
|
||||
const newScript = createDefaultScript()
|
||||
onScriptsChange([...scripts, newScript])
|
||||
setSelectedScript(newScript.id)
|
||||
toast.success('Script created')
|
||||
}
|
||||
|
||||
const handleDeleteScript = (scriptId: string) => {
|
||||
onScriptsChange(scripts.filter((script) => script.id !== scriptId))
|
||||
if (selectedScript === scriptId) {
|
||||
setSelectedScript(scripts.length > 1 ? scripts[0].id : null)
|
||||
}
|
||||
toast.success('Script deleted')
|
||||
}
|
||||
|
||||
const handleUpdateScript = (updates: Partial<LuaScript>) => {
|
||||
if (!currentScript) return
|
||||
|
||||
onScriptsChange(
|
||||
scripts.map((script) =>
|
||||
script.id === selectedScript ? { ...script, ...updates } : script
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddParameter = () => {
|
||||
if (!currentScript) return
|
||||
|
||||
const newParam = { name: `param${currentScript.parameters.length + 1}`, type: 'string' }
|
||||
handleUpdateScript({ parameters: [...currentScript.parameters, newParam] })
|
||||
}
|
||||
|
||||
const handleDeleteParameter = (index: number) => {
|
||||
if (!currentScript) return
|
||||
|
||||
handleUpdateScript({
|
||||
parameters: currentScript.parameters.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateParameter = (index: number, updates: { name?: string; type?: string }) => {
|
||||
if (!currentScript) return
|
||||
|
||||
handleUpdateScript({
|
||||
parameters: currentScript.parameters.map((param, i) =>
|
||||
i === index ? { ...param, ...updates } : param
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
currentScript,
|
||||
handleAddScript,
|
||||
handleDeleteScript,
|
||||
handleUpdateScript,
|
||||
handleAddParameter,
|
||||
handleDeleteParameter,
|
||||
handleUpdateParameter,
|
||||
}
|
||||
}
|
||||
200
frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
Normal file
200
frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon,
|
||||
ArrowDownward,
|
||||
ArrowUpward,
|
||||
ContentCopy,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material'
|
||||
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
|
||||
import styles from '../LuaBlocksEditor.module.scss'
|
||||
|
||||
interface BlockListProps {
|
||||
blocks: LuaBlock[]
|
||||
blockDefinitionMap: Map<LuaBlockType, BlockDefinition>
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void
|
||||
onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
|
||||
onDuplicateBlock: (blockId: string) => void
|
||||
onRemoveBlock: (blockId: string) => void
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
}
|
||||
|
||||
const renderBlockFields = (
|
||||
block: LuaBlock,
|
||||
definition: BlockDefinition,
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
) => {
|
||||
if (definition.fields.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box className={styles.blockFields}>
|
||||
{definition.fields.map((field) => (
|
||||
<Box key={field.name}>
|
||||
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
|
||||
{field.type === 'select' ? (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const renderBlockSection = (
|
||||
title: string,
|
||||
blocks: LuaBlock[] | undefined,
|
||||
parentId: string | null,
|
||||
slot: BlockSlot,
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void,
|
||||
renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
|
||||
) => (
|
||||
<Box className={styles.blockSection}>
|
||||
<Box className={styles.blockSectionHeader}>
|
||||
<Typography className={styles.blockSectionTitle}>{title}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
>
|
||||
Add block
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className={styles.blockSectionBody}>
|
||||
{blocks && blocks.length > 0 ? (
|
||||
blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
|
||||
) : (
|
||||
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const BlockList = ({
|
||||
blocks,
|
||||
blockDefinitionMap,
|
||||
onRequestAddBlock,
|
||||
onMoveBlock,
|
||||
onDuplicateBlock,
|
||||
onRemoveBlock,
|
||||
onUpdateField,
|
||||
}: BlockListProps) => {
|
||||
const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
|
||||
const definition = blockDefinitionMap.get(block.type)
|
||||
if (!definition) return null
|
||||
|
||||
return (
|
||||
<Box key={block.id} className={styles.blockCard} data-category={definition.category}>
|
||||
<Box className={styles.blockHeader}>
|
||||
<Typography className={styles.blockTitle}>{definition.label}</Typography>
|
||||
<Box className={styles.blockActions}>
|
||||
<Tooltip title="Move up">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'up')}
|
||||
disabled={index === 0}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowUpward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'down')}
|
||||
disabled={index === total - 1}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowDownward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDuplicateBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ContentCopy fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemoveBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<DeleteIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{renderBlockFields(block, definition, onUpdateField)}
|
||||
{definition.hasChildren &&
|
||||
renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
|
||||
{definition.hasElseChildren &&
|
||||
renderBlockSection(
|
||||
'Else',
|
||||
block.elseChildren,
|
||||
block.id,
|
||||
'elseChildren',
|
||||
onRequestAddBlock,
|
||||
renderBlockCard
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={styles.blockStack}>
|
||||
{blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Box, Menu, MenuItem, Typography } from '@mui/material'
|
||||
import type { BlockDefinition } from '../types'
|
||||
import styles from '../LuaBlocksEditor.module.scss'
|
||||
|
||||
interface BlockMenuProps {
|
||||
anchorEl: HTMLElement | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
blocks: BlockDefinition[]
|
||||
onSelect: (type: BlockDefinition['type']) => void
|
||||
}
|
||||
|
||||
export const BlockMenu = ({ anchorEl, open, onClose, blocks, onSelect }: BlockMenuProps) => (
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={onClose} PaperProps={{ sx: { minWidth: 280 } }}>
|
||||
{blocks.map((definition) => (
|
||||
<MenuItem key={definition.type} onClick={() => onSelect(definition.type)}>
|
||||
<Box className={styles.menuSwatch} data-category={definition.category} sx={{ mr: 1 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{definition.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{definition.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { BlockCategory, BlockDefinition, LuaBlock, LuaBlockType } from '../types'
|
||||
|
||||
const BLOCKS_METADATA_PREFIX = '--@blocks '
|
||||
|
||||
const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
||||
{
|
||||
type: 'log',
|
||||
label: 'Log message',
|
||||
description: 'Send a message to the Lua console',
|
||||
category: 'Basics',
|
||||
fields: [
|
||||
{
|
||||
name: 'message',
|
||||
label: 'Message',
|
||||
placeholder: '"Hello from Lua"',
|
||||
type: 'text',
|
||||
defaultValue: '"Hello from Lua"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'set_variable',
|
||||
label: 'Set variable',
|
||||
description: 'Create or update a variable',
|
||||
category: 'Data',
|
||||
fields: [
|
||||
{
|
||||
name: 'scope',
|
||||
label: 'Scope',
|
||||
type: 'select',
|
||||
defaultValue: 'local',
|
||||
options: [
|
||||
{ label: 'local', value: 'local' },
|
||||
{ label: 'global', value: 'global' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Variable name',
|
||||
placeholder: 'count',
|
||||
type: 'text',
|
||||
defaultValue: 'count',
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
label: 'Value',
|
||||
placeholder: '0',
|
||||
type: 'text',
|
||||
defaultValue: '0',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'if',
|
||||
label: 'If',
|
||||
description: 'Run blocks when a condition is true',
|
||||
category: 'Logic',
|
||||
fields: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
placeholder: 'context.data.isActive',
|
||||
type: 'text',
|
||||
defaultValue: 'context.data.isActive',
|
||||
},
|
||||
],
|
||||
hasChildren: true,
|
||||
},
|
||||
{
|
||||
type: 'if_else',
|
||||
label: 'If / Else',
|
||||
description: 'Branch execution with else fallback',
|
||||
category: 'Logic',
|
||||
fields: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
placeholder: 'context.data.count > 5',
|
||||
type: 'text',
|
||||
defaultValue: 'context.data.count > 5',
|
||||
},
|
||||
],
|
||||
hasChildren: true,
|
||||
hasElseChildren: true,
|
||||
},
|
||||
{
|
||||
type: 'repeat',
|
||||
label: 'Repeat loop',
|
||||
description: 'Run nested blocks multiple times',
|
||||
category: 'Loops',
|
||||
fields: [
|
||||
{
|
||||
name: 'iterator',
|
||||
label: 'Iterator',
|
||||
placeholder: 'i',
|
||||
type: 'text',
|
||||
defaultValue: 'i',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
label: 'Times',
|
||||
placeholder: '3',
|
||||
type: 'number',
|
||||
defaultValue: '3',
|
||||
},
|
||||
],
|
||||
hasChildren: true,
|
||||
},
|
||||
{
|
||||
type: 'call',
|
||||
label: 'Call function',
|
||||
description: 'Invoke a Lua function',
|
||||
category: 'Functions',
|
||||
fields: [
|
||||
{
|
||||
name: 'function',
|
||||
label: 'Function name',
|
||||
placeholder: 'my_function',
|
||||
type: 'text',
|
||||
defaultValue: 'my_function',
|
||||
},
|
||||
{
|
||||
name: 'args',
|
||||
label: 'Arguments',
|
||||
placeholder: 'context.data',
|
||||
type: 'text',
|
||||
defaultValue: 'context.data',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'return',
|
||||
label: 'Return',
|
||||
description: 'Return a value from the script',
|
||||
category: 'Basics',
|
||||
fields: [
|
||||
{
|
||||
name: 'value',
|
||||
label: 'Value',
|
||||
placeholder: 'true',
|
||||
type: 'text',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'comment',
|
||||
label: 'Comment',
|
||||
description: 'Add a comment to explain a step',
|
||||
category: 'Basics',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Comment',
|
||||
placeholder: 'Explain what happens here',
|
||||
type: 'text',
|
||||
defaultValue: 'Explain what happens here',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const createBlockId = () => `block_${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const indent = (depth: number) => ' '.repeat(depth)
|
||||
|
||||
const renderBlocks = (blocks: LuaBlock[], depth: number, renderBlock: (block: LuaBlock, depth: number) => string) =>
|
||||
blocks
|
||||
.map((block) => renderBlock(block, depth))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
export function useBlockDefinitions() {
|
||||
const blockDefinitionMap = useMemo(
|
||||
() => new Map<LuaBlockType, BlockDefinition>(BLOCK_DEFINITIONS.map((definition) => [definition.type, definition])),
|
||||
[]
|
||||
)
|
||||
|
||||
const blocksByCategory = useMemo<Record<BlockCategory, BlockDefinition[]>>(() => {
|
||||
const initial: Record<BlockCategory, BlockDefinition[]> = {
|
||||
Basics: [],
|
||||
Logic: [],
|
||||
Loops: [],
|
||||
Data: [],
|
||||
Functions: [],
|
||||
}
|
||||
|
||||
return BLOCK_DEFINITIONS.reduce((acc, definition) => {
|
||||
acc[definition.category] = [...(acc[definition.category] || []), definition]
|
||||
return acc
|
||||
}, initial)
|
||||
}, [])
|
||||
|
||||
const createBlock = useCallback(
|
||||
(type: LuaBlockType): LuaBlock => {
|
||||
const definition = blockDefinitionMap.get(type)
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown block type: ${type}`)
|
||||
}
|
||||
|
||||
const fields = definition.fields.reduce<Record<string, string>>((acc, field) => {
|
||||
acc[field.name] = field.defaultValue
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
id: createBlockId(),
|
||||
type,
|
||||
fields,
|
||||
children: definition.hasChildren ? [] : undefined,
|
||||
elseChildren: definition.hasElseChildren ? [] : undefined,
|
||||
}
|
||||
},
|
||||
[blockDefinitionMap]
|
||||
)
|
||||
|
||||
const cloneBlock = useCallback(
|
||||
(block: LuaBlock): LuaBlock => ({
|
||||
...block,
|
||||
id: createBlockId(),
|
||||
fields: { ...block.fields },
|
||||
children: block.children ? block.children.map(cloneBlock) : undefined,
|
||||
elseChildren: block.elseChildren ? block.elseChildren.map(cloneBlock) : undefined,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const getFieldValue = useCallback((block: LuaBlock, fieldName: string, fallback: string) => {
|
||||
const value = block.fields[fieldName]
|
||||
if (value === undefined || value === null) return fallback
|
||||
const normalized = String(value).trim()
|
||||
return normalized.length > 0 ? normalized : fallback
|
||||
}, [])
|
||||
|
||||
const renderChildBlocks = useCallback(
|
||||
(blocks: LuaBlock[] | undefined, depth: number, renderBlock: (block: LuaBlock, depth: number) => string) => {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return `${indent(depth)}-- add blocks here`
|
||||
}
|
||||
return renderBlocks(blocks, depth, renderBlock)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const buildLuaFromBlocks = useCallback(
|
||||
(blocks: LuaBlock[]) => {
|
||||
const renderBlock = (block: LuaBlock, depth: number): string => {
|
||||
switch (block.type) {
|
||||
case 'log': {
|
||||
const message = getFieldValue(block, 'message', '""')
|
||||
return `${indent(depth)}log(${message})`
|
||||
}
|
||||
case 'set_variable': {
|
||||
const scope = getFieldValue(block, 'scope', 'local')
|
||||
const name = getFieldValue(block, 'name', 'value')
|
||||
const value = getFieldValue(block, 'value', 'nil')
|
||||
const keyword = scope === 'local' ? 'local ' : ''
|
||||
return `${indent(depth)}${keyword}${name} = ${value}`
|
||||
}
|
||||
case 'if': {
|
||||
const condition = getFieldValue(block, 'condition', 'true')
|
||||
const body = renderChildBlocks(block.children, depth + 1, renderBlock)
|
||||
return `${indent(depth)}if ${condition} then\n${body}\n${indent(depth)}end`
|
||||
}
|
||||
case 'if_else': {
|
||||
const condition = getFieldValue(block, 'condition', 'true')
|
||||
const thenBody = renderChildBlocks(block.children, depth + 1, renderBlock)
|
||||
const elseBody = renderChildBlocks(block.elseChildren, depth + 1, renderBlock)
|
||||
return `${indent(depth)}if ${condition} then\n${thenBody}\n${indent(depth)}else\n${elseBody}\n${indent(depth)}end`
|
||||
}
|
||||
case 'repeat': {
|
||||
const iterator = getFieldValue(block, 'iterator', 'i')
|
||||
const count = getFieldValue(block, 'count', '1')
|
||||
const body = renderChildBlocks(block.children, depth + 1, renderBlock)
|
||||
return `${indent(depth)}for ${iterator} = 1, ${count} do\n${body}\n${indent(depth)}end`
|
||||
}
|
||||
case 'return': {
|
||||
const value = getFieldValue(block, 'value', 'nil')
|
||||
return `${indent(depth)}return ${value}`
|
||||
}
|
||||
case 'call': {
|
||||
const functionName = getFieldValue(block, 'function', 'my_function')
|
||||
const args = getFieldValue(block, 'args', '')
|
||||
const argsSection = args ? args : ''
|
||||
return `${indent(depth)}${functionName}(${argsSection})`
|
||||
}
|
||||
case 'comment': {
|
||||
const text = getFieldValue(block, 'text', '')
|
||||
return `${indent(depth)}-- ${text}`
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = `${BLOCKS_METADATA_PREFIX}${JSON.stringify({ version: 1, blocks })}`
|
||||
const body = renderBlocks(blocks, 0, renderBlock)
|
||||
if (!body.trim()) {
|
||||
return `${metadata}\n-- empty block workspace\n`
|
||||
}
|
||||
return `${metadata}\n${body}\n`
|
||||
},
|
||||
[getFieldValue, renderChildBlocks]
|
||||
)
|
||||
|
||||
const decodeBlocksMetadata = useCallback((code: string): LuaBlock[] | null => {
|
||||
const metadataLine = code
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith(BLOCKS_METADATA_PREFIX))
|
||||
|
||||
if (!metadataLine) return null
|
||||
|
||||
const json = metadataLine.slice(BLOCKS_METADATA_PREFIX.length)
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (!parsed || !Array.isArray(parsed.blocks)) return null
|
||||
return parsed.blocks as LuaBlock[]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
blockDefinitions: BLOCK_DEFINITIONS,
|
||||
blockDefinitionMap,
|
||||
blocksByCategory,
|
||||
createBlock,
|
||||
cloneBlock,
|
||||
buildLuaFromBlocks,
|
||||
decodeBlocksMetadata,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
import type { BlockSlot, LuaBlock, LuaBlockType } from '../types'
|
||||
|
||||
interface UseLuaBlocksStateProps {
|
||||
scripts: LuaScript[]
|
||||
onScriptsChange: (scripts: LuaScript[]) => void
|
||||
buildLuaFromBlocks: (blocks: LuaBlock[]) => string
|
||||
createBlock: (type: LuaBlockType) => LuaBlock
|
||||
cloneBlock: (block: LuaBlock) => LuaBlock
|
||||
decodeBlocksMetadata: (code: string) => LuaBlock[] | null
|
||||
}
|
||||
|
||||
interface MenuTarget {
|
||||
parentId: string | null
|
||||
slot: BlockSlot
|
||||
}
|
||||
|
||||
const addBlockToTree = (
|
||||
blocks: LuaBlock[],
|
||||
parentId: string | null,
|
||||
slot: BlockSlot,
|
||||
newBlock: LuaBlock
|
||||
): LuaBlock[] => {
|
||||
if (slot === 'root' || !parentId) {
|
||||
return [...blocks, newBlock]
|
||||
}
|
||||
|
||||
return blocks.map((block) => {
|
||||
if (block.id === parentId) {
|
||||
const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
|
||||
const updated = [...current, newBlock]
|
||||
if (slot === 'children') {
|
||||
return { ...block, children: updated }
|
||||
}
|
||||
return { ...block, elseChildren: updated }
|
||||
}
|
||||
|
||||
const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
}
|
||||
|
||||
const updateBlockInTree = (
|
||||
blocks: LuaBlock[],
|
||||
blockId: string,
|
||||
updater: (block: LuaBlock) => LuaBlock
|
||||
): LuaBlock[] =>
|
||||
blocks.map((block) => {
|
||||
if (block.id === blockId) {
|
||||
return updater(block)
|
||||
}
|
||||
|
||||
const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? updateBlockInTree(block.elseChildren, blockId, updater)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
|
||||
blocks
|
||||
.filter((block) => block.id !== blockId)
|
||||
.map((block) => {
|
||||
const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? removeBlockFromTree(block.elseChildren, blockId)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
|
||||
const index = blocks.findIndex((block) => block.id === blockId)
|
||||
if (index !== -1) {
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
|
||||
|
||||
const updated = [...blocks]
|
||||
const [moved] = updated.splice(index, 1)
|
||||
updated.splice(targetIndex, 0, moved)
|
||||
return updated
|
||||
}
|
||||
|
||||
return blocks.map((block) => {
|
||||
const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? moveBlockInTree(block.elseChildren, blockId, direction)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
}
|
||||
|
||||
export function useLuaBlocksState({
|
||||
scripts,
|
||||
onScriptsChange,
|
||||
buildLuaFromBlocks,
|
||||
createBlock,
|
||||
cloneBlock,
|
||||
decodeBlocksMetadata,
|
||||
}: UseLuaBlocksStateProps) {
|
||||
const [selectedScriptId, setSelectedScriptId] = useState<string | null>(
|
||||
scripts.length > 0 ? scripts[0].id : null
|
||||
)
|
||||
const [blocksByScript, setBlocksByScript] = useState<Record<string, LuaBlock[]>>({})
|
||||
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null)
|
||||
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (scripts.length === 0) {
|
||||
setSelectedScriptId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedScriptId || !scripts.find((script) => script.id === selectedScriptId)) {
|
||||
setSelectedScriptId(scripts[0].id)
|
||||
}
|
||||
}, [scripts, selectedScriptId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScriptId) return
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(blocksByScript, selectedScriptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = scripts.find((item) => item.id === selectedScriptId)
|
||||
const parsedBlocks = script ? decodeBlocksMetadata(script.code) : null
|
||||
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: parsedBlocks ?? [],
|
||||
}))
|
||||
}, [blocksByScript, decodeBlocksMetadata, scripts, selectedScriptId])
|
||||
|
||||
const selectedScript = scripts.find((script) => script.id === selectedScriptId) || null
|
||||
const activeBlocks = selectedScriptId ? blocksByScript[selectedScriptId] || [] : []
|
||||
const generatedCode = useMemo(() => buildLuaFromBlocks(activeBlocks), [activeBlocks, buildLuaFromBlocks])
|
||||
|
||||
const handleAddScript = () => {
|
||||
const starterBlocks = [createBlock('log')]
|
||||
const newScript: LuaScript = {
|
||||
id: `lua_${Date.now()}`,
|
||||
name: 'Block Script',
|
||||
description: 'Built with Lua blocks',
|
||||
code: buildLuaFromBlocks(starterBlocks),
|
||||
parameters: [],
|
||||
}
|
||||
|
||||
onScriptsChange([...scripts, newScript])
|
||||
setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
|
||||
setSelectedScriptId(newScript.id)
|
||||
toast.success('Block script created')
|
||||
}
|
||||
|
||||
const handleDeleteScript = (scriptId: string) => {
|
||||
const remaining = scripts.filter((script) => script.id !== scriptId)
|
||||
onScriptsChange(remaining)
|
||||
|
||||
setBlocksByScript((prev) => {
|
||||
const { [scriptId]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
if (selectedScriptId === scriptId) {
|
||||
setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
|
||||
}
|
||||
|
||||
toast.success('Script deleted')
|
||||
}
|
||||
|
||||
const handleUpdateScript = (updates: Partial<LuaScript>) => {
|
||||
if (!selectedScript) return
|
||||
onScriptsChange(
|
||||
scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
|
||||
)
|
||||
}
|
||||
|
||||
const handleApplyCode = () => {
|
||||
if (!selectedScript) return
|
||||
handleUpdateScript({ code: generatedCode })
|
||||
toast.success('Lua code updated from blocks')
|
||||
}
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedCode)
|
||||
toast.success('Lua code copied to clipboard')
|
||||
} catch (error) {
|
||||
toast.error('Unable to copy code')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReloadFromCode = () => {
|
||||
if (!selectedScript) return
|
||||
const parsed = decodeBlocksMetadata(selectedScript.code)
|
||||
if (!parsed) {
|
||||
toast.warning('No block metadata found in this script')
|
||||
return
|
||||
}
|
||||
setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
|
||||
toast.success('Blocks loaded from script')
|
||||
}
|
||||
|
||||
const handleRequestAddBlock = (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => {
|
||||
setMenuAnchor(event.currentTarget)
|
||||
setMenuTarget(target)
|
||||
}
|
||||
|
||||
const handleAddBlock = (type: LuaBlockType, target?: { parentId: string | null; slot: BlockSlot }) => {
|
||||
const resolvedTarget = target ?? menuTarget
|
||||
if (!selectedScriptId || !resolvedTarget) return
|
||||
|
||||
const newBlock = createBlock(type)
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: addBlockToTree(
|
||||
prev[selectedScriptId] || [],
|
||||
resolvedTarget.parentId,
|
||||
resolvedTarget.slot,
|
||||
newBlock
|
||||
),
|
||||
}))
|
||||
|
||||
setMenuAnchor(null)
|
||||
setMenuTarget(null)
|
||||
}
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setMenuAnchor(null)
|
||||
setMenuTarget(null)
|
||||
}
|
||||
|
||||
const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
|
||||
if (!selectedScriptId) return
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
|
||||
...block,
|
||||
fields: {
|
||||
...block.fields,
|
||||
[fieldName]: value,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveBlock = (blockId: string) => {
|
||||
if (!selectedScriptId) return
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDuplicateBlock = (blockId: string) => {
|
||||
if (!selectedScriptId) return
|
||||
|
||||
setBlocksByScript((prev) => {
|
||||
const blocks = prev[selectedScriptId] || []
|
||||
let duplicated: LuaBlock | null = null
|
||||
|
||||
const updated = updateBlockInTree(blocks, blockId, (block) => {
|
||||
duplicated = cloneBlock(block)
|
||||
return block
|
||||
})
|
||||
|
||||
if (!duplicated) return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
|
||||
if (!selectedScriptId) return
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
activeBlocks,
|
||||
generatedCode,
|
||||
handleAddBlock,
|
||||
handleAddScript,
|
||||
handleApplyCode,
|
||||
handleCloseMenu,
|
||||
handleCopyCode,
|
||||
handleDeleteScript,
|
||||
handleDuplicateBlock,
|
||||
handleMoveBlock,
|
||||
handleReloadFromCode,
|
||||
handleRemoveBlock,
|
||||
handleRequestAddBlock,
|
||||
handleUpdateField,
|
||||
handleUpdateScript,
|
||||
menuAnchor,
|
||||
menuTarget,
|
||||
selectedScript,
|
||||
selectedScriptId,
|
||||
setSelectedScriptId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Card, CardContent } from '@/components/ui'
|
||||
import { LuaCodeEditorSection } from './code/LuaCodeEditorSection'
|
||||
import { LuaScriptDetails } from './configuration/LuaScriptDetails'
|
||||
import { LuaScriptsListCard } from './configuration/LuaScriptsListCard'
|
||||
import { LuaExecutionPreview } from './execution/LuaExecutionPreview'
|
||||
import { LuaLintingControls } from './linting/LuaLintingControls'
|
||||
import { LuaEditorToolbar } from './toolbar/LuaEditorToolbar'
|
||||
import { useLuaEditorLogic } from './useLuaEditorLogic'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
|
||||
interface LuaEditorProps {
|
||||
scripts: LuaScript[]
|
||||
onScriptsChange: (scripts: LuaScript[]) => void
|
||||
}
|
||||
|
||||
export const LuaEditor = ({ scripts, onScriptsChange }: LuaEditorProps) => {
|
||||
const {
|
||||
currentScript,
|
||||
selectedScriptId,
|
||||
testOutput,
|
||||
testInputs,
|
||||
isExecuting,
|
||||
isFullscreen,
|
||||
showSnippetLibrary,
|
||||
securityScanResult,
|
||||
showSecurityDialog,
|
||||
setSelectedScriptId,
|
||||
setIsFullscreen,
|
||||
setShowSnippetLibrary,
|
||||
setShowSecurityDialog,
|
||||
handleAddScript,
|
||||
handleDeleteScript,
|
||||
handleUpdateScript,
|
||||
handleAddParameter,
|
||||
handleDeleteParameter,
|
||||
handleUpdateParameter,
|
||||
handleTestInputChange,
|
||||
handleScanCode,
|
||||
handleTestScript,
|
||||
handleProceedWithExecution,
|
||||
} = useLuaEditorLogic({ scripts, onScriptsChange })
|
||||
|
||||
if (!currentScript) {
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6 h-full">
|
||||
<LuaScriptsListCard
|
||||
scripts={scripts}
|
||||
selectedScriptId={selectedScriptId}
|
||||
onAddScript={handleAddScript}
|
||||
onDeleteScript={handleDeleteScript}
|
||||
onSelectScript={setSelectedScriptId}
|
||||
/>
|
||||
<Card className="md:col-span-2">
|
||||
<CardContent className="flex items-center justify-center h-full min-h-[400px]">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Select or create a script to edit</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6 h-full">
|
||||
<LuaScriptsListCard
|
||||
scripts={scripts}
|
||||
selectedScriptId={selectedScriptId}
|
||||
onAddScript={handleAddScript}
|
||||
onDeleteScript={handleDeleteScript}
|
||||
onSelectScript={setSelectedScriptId}
|
||||
/>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<LuaEditorToolbar
|
||||
script={currentScript}
|
||||
isExecuting={isExecuting}
|
||||
onScan={handleScanCode}
|
||||
onTest={handleTestScript}
|
||||
/>
|
||||
<LuaScriptDetails
|
||||
script={currentScript}
|
||||
testInputs={testInputs}
|
||||
onUpdateScript={handleUpdateScript}
|
||||
onAddParameter={handleAddParameter}
|
||||
onDeleteParameter={handleDeleteParameter}
|
||||
onUpdateParameter={handleUpdateParameter}
|
||||
onTestInputChange={handleTestInputChange}
|
||||
/>
|
||||
<CardContent className="space-y-6">
|
||||
<LuaCodeEditorSection
|
||||
script={currentScript}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
|
||||
showSnippetLibrary={showSnippetLibrary}
|
||||
onShowSnippetLibraryChange={setShowSnippetLibrary}
|
||||
onUpdateScript={handleUpdateScript}
|
||||
/>
|
||||
<LuaExecutionPreview result={testOutput} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LuaLintingControls
|
||||
scanResult={securityScanResult}
|
||||
showDialog={showSecurityDialog}
|
||||
onDialogChange={setShowSecurityDialog}
|
||||
onProceed={handleProceedWithExecution}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useRef } from 'react'
|
||||
import Editor, { useMonaco } from '@monaco-editor/react'
|
||||
import { ArrowsOut, BookOpen, FileCode } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary'
|
||||
import { getLuaExampleCode, getLuaExamplesList } from '@/lib/lua-examples'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui'
|
||||
import { useLuaMonacoConfig } from './useLuaMonacoConfig'
|
||||
|
||||
interface LuaCodeEditorSectionProps {
|
||||
script: LuaScript
|
||||
isFullscreen: boolean
|
||||
onToggleFullscreen: () => void
|
||||
showSnippetLibrary: boolean
|
||||
onShowSnippetLibraryChange: (open: boolean) => void
|
||||
onUpdateScript: (updates: Partial<LuaScript>) => void
|
||||
}
|
||||
|
||||
export const LuaCodeEditorSection = ({
|
||||
script,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
showSnippetLibrary,
|
||||
onShowSnippetLibraryChange,
|
||||
onUpdateScript,
|
||||
}: LuaCodeEditorSectionProps) => {
|
||||
const editorRef = useRef<any>(null)
|
||||
const monaco = useMonaco()
|
||||
|
||||
useLuaMonacoConfig(monaco)
|
||||
|
||||
const handleInsertSnippet = (code: string) => {
|
||||
if (editorRef.current) {
|
||||
const selection = editorRef.current.getSelection()
|
||||
if (selection) {
|
||||
editorRef.current.executeEdits('', [{
|
||||
range: selection,
|
||||
text: code,
|
||||
forceMoveMarkers: true
|
||||
}])
|
||||
editorRef.current.focus()
|
||||
}
|
||||
}
|
||||
|
||||
if (!editorRef.current) {
|
||||
const currentCode = script.code
|
||||
const newCode = currentCode ? `${currentCode}\n\n${code}` : code
|
||||
onUpdateScript({ code: newCode })
|
||||
}
|
||||
|
||||
onShowSnippetLibraryChange(false)
|
||||
}
|
||||
|
||||
const handleExampleLoad = (value: string) => {
|
||||
const exampleCode = getLuaExampleCode(value as any)
|
||||
onUpdateScript({ code: exampleCode })
|
||||
toast.success('Example loaded')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Lua Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Sheet open={showSnippetLibrary} onOpenChange={onShowSnippetLibraryChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen size={16} className="mr-2" />
|
||||
Snippet Library
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Lua Snippet Library</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse and insert pre-built code templates
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
<LuaSnippetLibrary onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Select onValueChange={handleExampleLoad}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<FileCode size={16} className="mr-2" />
|
||||
<SelectValue placeholder="Examples" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getLuaExamplesList().map((example) => (
|
||||
<SelectItem key={example.key} value={example.key}>
|
||||
<div>
|
||||
<div className="font-medium">{example.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{example.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
<ArrowsOut size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`border rounded-lg overflow-hidden ${isFullscreen ? 'fixed inset-4 z-50 bg-background' : ''}`}>
|
||||
<Editor
|
||||
height={isFullscreen ? 'calc(100vh - 8rem)' : '400px'}
|
||||
language="lua"
|
||||
value={script.code}
|
||||
onChange={(value) => onUpdateScript({ code: value || '' })}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
}}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: isFullscreen },
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: true,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
acceptSuggestionOnEnter: 'on',
|
||||
snippetSuggestions: 'inline',
|
||||
parameterHints: { enabled: true },
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Write Lua code. Access parameters via <code className="font-mono">context.data</code>. Use <code className="font-mono">log()</code> or <code className="font-mono">print()</code> for output. Press <code className="font-mono">Ctrl+Space</code> for autocomplete.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { Monaco } from '@monaco-editor/react'
|
||||
|
||||
export const useLuaMonacoConfig = (monaco: Monaco | null) => {
|
||||
useEffect(() => {
|
||||
if (!monaco) return
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('lua', {
|
||||
provideCompletionItems: (model, position) => {
|
||||
const word = model.getWordUntilPosition(position)
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
}
|
||||
|
||||
const suggestions: any[] = [
|
||||
{
|
||||
label: 'context.data',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.data',
|
||||
documentation: 'Access input parameters passed to the script',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'context.user',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.user',
|
||||
documentation: 'Current user information (username, role, etc.)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'context.kv',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.kv',
|
||||
documentation: 'Key-value storage interface',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'context.log',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'context.log(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Log a message to the output console',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'log',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'log(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Log a message (shortcut for context.log)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'print',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'print(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Print a message to output',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'return',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: 'return ${1:result}',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Return a value from the script',
|
||||
range
|
||||
},
|
||||
]
|
||||
|
||||
return { suggestions }
|
||||
}
|
||||
})
|
||||
|
||||
monaco.languages.setLanguageConfiguration('lua', {
|
||||
comments: {
|
||||
lineComment: '--',
|
||||
blockComment: ['--[[', ']]']
|
||||
},
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')']
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" }
|
||||
]
|
||||
})
|
||||
}, [monaco])
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Plus, Trash } from '@phosphor-icons/react'
|
||||
import { Badge, Button, CardContent, Input, Label } from '@/components/ui'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
|
||||
interface LuaScriptDetailsProps {
|
||||
script: LuaScript
|
||||
testInputs: Record<string, any>
|
||||
onUpdateScript: (updates: Partial<LuaScript>) => void
|
||||
onAddParameter: () => void
|
||||
onDeleteParameter: (index: number) => void
|
||||
onUpdateParameter: (index: number, updates: { name?: string; type?: string }) => void
|
||||
onTestInputChange: (paramName: string, value: any) => void
|
||||
}
|
||||
|
||||
export const LuaScriptDetails = ({
|
||||
script,
|
||||
testInputs,
|
||||
onUpdateScript,
|
||||
onAddParameter,
|
||||
onDeleteParameter,
|
||||
onUpdateParameter,
|
||||
onTestInputChange,
|
||||
}: LuaScriptDetailsProps) => (
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Script Name</Label>
|
||||
<Input
|
||||
value={script.name}
|
||||
onChange={(e) => onUpdateScript({ name: e.target.value })}
|
||||
placeholder="validate_user"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Return Type</Label>
|
||||
<Input
|
||||
value={script.returnType || ''}
|
||||
onChange={(e) => onUpdateScript({ returnType: e.target.value })}
|
||||
placeholder="table, boolean, string..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={script.description || ''}
|
||||
onChange={(e) => onUpdateScript({ description: e.target.value })}
|
||||
placeholder="What this script does..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Parameters</Label>
|
||||
<Button size="sm" variant="outline" onClick={onAddParameter}>
|
||||
<Plus className="mr-2" size={14} />
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{script.parameters.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-3 border border-dashed rounded-lg">
|
||||
No parameters defined
|
||||
</p>
|
||||
) : (
|
||||
script.parameters.map((param, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={param.name}
|
||||
onChange={(e) => onUpdateParameter(index, { name: e.target.value })}
|
||||
placeholder="paramName"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={param.type}
|
||||
onChange={(e) => onUpdateParameter(index, { type: e.target.value })}
|
||||
placeholder="string"
|
||||
className="w-32 text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteParameter(index)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{script.parameters.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Test Input Values</Label>
|
||||
<div className="space-y-2">
|
||||
{script.parameters.map((param) => (
|
||||
<div key={param.name} className="flex gap-2 items-center">
|
||||
<Label className="w-32 text-sm font-mono">{param.name}</Label>
|
||||
<Input
|
||||
value={testInputs[param.name] ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = param.type === 'number'
|
||||
? parseFloat(e.target.value) || 0
|
||||
: param.type === 'boolean'
|
||||
? e.target.value === 'true'
|
||||
: e.target.value
|
||||
onTestInputChange(param.name, value)
|
||||
}}
|
||||
placeholder={`Enter ${param.type} value`}
|
||||
className="flex-1 text-sm"
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{param.type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Plus, Trash } from '@phosphor-icons/react'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
|
||||
interface LuaScriptsListCardProps {
|
||||
scripts: LuaScript[]
|
||||
selectedScriptId: string | null
|
||||
onAddScript: () => void
|
||||
onDeleteScript: (id: string) => void
|
||||
onSelectScript: (id: string) => void
|
||||
}
|
||||
|
||||
export const LuaScriptsListCard = ({
|
||||
scripts,
|
||||
selectedScriptId,
|
||||
onAddScript,
|
||||
onDeleteScript,
|
||||
onSelectScript,
|
||||
}: LuaScriptsListCardProps) => (
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Lua Scripts</CardTitle>
|
||||
<Button size="sm" onClick={onAddScript}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>Custom logic scripts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{scripts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No scripts yet. Create one to start.
|
||||
</p>
|
||||
) : (
|
||||
scripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedScriptId === script.id
|
||||
? 'bg-accent border-accent-foreground'
|
||||
: 'hover:bg-muted border-border'
|
||||
}`}
|
||||
onClick={() => onSelectScript(script.id)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm font-mono">{script.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{script.parameters.length} params
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteScript(script.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CheckCircle, XCircle } from '@phosphor-icons/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, Label } from '@/components/ui'
|
||||
import type { LuaExecutionResult } from '@/lib/lua-engine'
|
||||
|
||||
interface LuaExecutionPreviewProps {
|
||||
result: LuaExecutionResult | null
|
||||
}
|
||||
|
||||
export const LuaExecutionPreview = ({ result }: LuaExecutionPreviewProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{result && (
|
||||
<Card className={result.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-600" />
|
||||
)}
|
||||
<CardTitle className="text-sm">
|
||||
{result.success ? 'Execution Successful' : 'Execution Failed'}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{result.error && (
|
||||
<div>
|
||||
<Label className="text-xs text-red-600 mb-1">Error</Label>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap text-red-700 bg-red-100 p-2 rounded">
|
||||
{result.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.logs.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs mb-1">Logs</Label>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded">
|
||||
{result.logs.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.result !== null && result.result !== undefined && (
|
||||
<div>
|
||||
<Label className="text-xs mb-1">Return Value</Label>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded">
|
||||
{JSON.stringify(result.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-dashed space-y-2 text-xs text-muted-foreground">
|
||||
<p className="font-semibold text-foreground">Available in context:</p>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li><code className="font-mono">context.data</code> - Input data</li>
|
||||
<li><code className="font-mono">context.user</code> - Current user info</li>
|
||||
<li><code className="font-mono">context.kv</code> - Key-value storage</li>
|
||||
<li><code className="font-mono">context.log(msg)</code> - Logging function</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog'
|
||||
import type { SecurityScanResult } from '@/lib/security-scanner'
|
||||
|
||||
interface LuaLintingControlsProps {
|
||||
scanResult: SecurityScanResult | null
|
||||
showDialog: boolean
|
||||
onDialogChange: (open: boolean) => void
|
||||
onProceed: () => void
|
||||
}
|
||||
|
||||
export const LuaLintingControls = ({
|
||||
scanResult,
|
||||
showDialog,
|
||||
onDialogChange,
|
||||
onProceed,
|
||||
}: LuaLintingControlsProps) => {
|
||||
if (!scanResult) return null
|
||||
|
||||
return (
|
||||
<SecurityWarningDialog
|
||||
open={showDialog}
|
||||
onOpenChange={onDialogChange}
|
||||
scanResult={scanResult}
|
||||
onProceed={onProceed}
|
||||
onCancel={() => onDialogChange(false)}
|
||||
codeType="Lua script"
|
||||
showProceedButton
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Play, ShieldCheck } from '@phosphor-icons/react'
|
||||
import { Button, CardHeader, CardTitle, CardDescription } from '@/components/ui'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
|
||||
interface LuaEditorToolbarProps {
|
||||
script: LuaScript
|
||||
isExecuting: boolean
|
||||
onScan: () => void
|
||||
onTest: () => void
|
||||
}
|
||||
|
||||
export const LuaEditorToolbar = ({
|
||||
script,
|
||||
isExecuting,
|
||||
onScan,
|
||||
onTest,
|
||||
}: LuaEditorToolbarProps) => (
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Edit Script: {script.name}</CardTitle>
|
||||
<CardDescription>Write custom Lua logic</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onScan}>
|
||||
<ShieldCheck className="mr-2" size={16} />
|
||||
Security Scan
|
||||
</Button>
|
||||
<Button onClick={onTest} disabled={isExecuting}>
|
||||
<Play className="mr-2" size={16} />
|
||||
{isExecuting ? 'Executing...' : 'Test Script'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
|
||||
import type { LuaExecutionResult } from '@/lib/lua-engine'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
|
||||
|
||||
interface UseLuaEditorLogicProps {
|
||||
scripts: LuaScript[]
|
||||
onScriptsChange: (scripts: LuaScript[]) => void
|
||||
}
|
||||
|
||||
const defaultCode = '-- Lua script example\n-- Access input parameters via context.data\n-- Use log() or print() to output messages\n\nlog("Script started")\n\nif context.data then\n log("Received data:", context.data)\nend\n\nlocal result = {\n success = true,\n message = "Script executed successfully"\n}\n\nreturn result'
|
||||
|
||||
export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogicProps) => {
|
||||
const [selectedScriptId, setSelectedScriptId] = useState<string | null>(scripts.length > 0 ? scripts[0].id : null)
|
||||
const [testOutput, setTestOutput] = useState<LuaExecutionResult | null>(null)
|
||||
const [testInputs, setTestInputs] = useState<Record<string, any>>({})
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showSnippetLibrary, setShowSnippetLibrary] = useState(false)
|
||||
const [securityScanResult, setSecurityScanResult] = useState<SecurityScanResult | null>(null)
|
||||
const [showSecurityDialog, setShowSecurityDialog] = useState(false)
|
||||
|
||||
const currentScript = useMemo(() => scripts.find((script) => script.id === selectedScriptId), [scripts, selectedScriptId])
|
||||
|
||||
useEffect(() => {
|
||||
if (scripts.length > 0 && !selectedScriptId) setSelectedScriptId(scripts[0].id)
|
||||
}, [scripts, selectedScriptId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentScript) return
|
||||
const inputs: Record<string, any> = {}
|
||||
currentScript.parameters.forEach((param) => {
|
||||
inputs[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : ''
|
||||
})
|
||||
setTestInputs(inputs)
|
||||
}, [currentScript?.parameters.length, selectedScriptId])
|
||||
|
||||
const handleAddScript = () => {
|
||||
const newScript: LuaScript = { id: `lua_${Date.now()}`, name: 'New Script', code: defaultCode, parameters: [] }
|
||||
onScriptsChange([...scripts, newScript])
|
||||
setSelectedScriptId(newScript.id)
|
||||
toast.success('Script created')
|
||||
}
|
||||
|
||||
const handleDeleteScript = (scriptId: string) => {
|
||||
onScriptsChange(scripts.filter((s) => s.id !== scriptId))
|
||||
if (selectedScriptId === scriptId) setSelectedScriptId(scripts.length > 1 ? scripts[0].id : null)
|
||||
toast.success('Script deleted')
|
||||
}
|
||||
|
||||
const handleUpdateScript = (updates: Partial<LuaScript>) => {
|
||||
if (!currentScript) return
|
||||
onScriptsChange(scripts.map((script) => (script.id === currentScript.id ? { ...script, ...updates } : script)))
|
||||
}
|
||||
|
||||
const handleAddParameter = () => currentScript && handleUpdateScript({ parameters: [...currentScript.parameters, { name: `param${currentScript.parameters.length + 1}`, type: 'string' }] })
|
||||
const handleDeleteParameter = (index: number) => currentScript && handleUpdateScript({ parameters: currentScript.parameters.filter((_, i) => i !== index) })
|
||||
const handleUpdateParameter = (index: number, updates: { name?: string; type?: string }) => currentScript && handleUpdateScript({ parameters: currentScript.parameters.map((p, i) => (i === index ? { ...p, ...updates } : p)) })
|
||||
const handleTestInputChange = (paramName: string, value: any) => setTestInputs({ ...testInputs, [paramName]: value })
|
||||
|
||||
const executeScript = async () => {
|
||||
if (!currentScript) return
|
||||
setIsExecuting(true)
|
||||
setTestOutput(null)
|
||||
try {
|
||||
const contextData: any = {}
|
||||
currentScript.parameters.forEach((param) => {
|
||||
contextData[param.name] = testInputs[param.name]
|
||||
})
|
||||
const result = await executeLuaScriptWithProfile(currentScript.code, { data: contextData, user: { username: 'test_user', role: 'god' }, log: (...args: any[]) => console.log('[Lua]', ...args) }, currentScript)
|
||||
setTestOutput(result)
|
||||
toast[result.success ? 'success' : 'error'](result.success ? 'Script executed successfully' : 'Script execution failed')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error('Execution error: ' + message)
|
||||
setTestOutput({ success: false, error: message, logs: [] })
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runSecurityScan = () => {
|
||||
if (!currentScript) return null
|
||||
const scanResult = securityScanner.scanLua(currentScript.code)
|
||||
setSecurityScanResult(scanResult)
|
||||
return scanResult
|
||||
}
|
||||
|
||||
const handleTestScript = async () => {
|
||||
if (!currentScript) return
|
||||
const scanResult = runSecurityScan()
|
||||
if (!scanResult) return
|
||||
if (scanResult.severity === 'critical' || scanResult.severity === 'high') {
|
||||
setShowSecurityDialog(true)
|
||||
toast.warning('Security issues detected in script')
|
||||
return
|
||||
}
|
||||
if (scanResult.severity === 'medium' && scanResult.issues.length > 0) {
|
||||
toast.warning(`${scanResult.issues.length} security warning(s) detected`)
|
||||
}
|
||||
await executeScript()
|
||||
}
|
||||
|
||||
const handleScanCode = () => {
|
||||
const scanResult = runSecurityScan()
|
||||
if (!scanResult) return
|
||||
setShowSecurityDialog(true)
|
||||
if (scanResult.safe) toast.success('No security issues detected')
|
||||
else toast.warning(`${scanResult.issues.length} security issue(s) detected`)
|
||||
}
|
||||
|
||||
const handleProceedWithExecution = async () => {
|
||||
setShowSecurityDialog(false)
|
||||
await executeScript()
|
||||
}
|
||||
|
||||
return {
|
||||
currentScript,
|
||||
selectedScriptId,
|
||||
testOutput,
|
||||
testInputs,
|
||||
isExecuting,
|
||||
isFullscreen,
|
||||
showSnippetLibrary,
|
||||
securityScanResult,
|
||||
showSecurityDialog,
|
||||
setSelectedScriptId,
|
||||
setIsFullscreen,
|
||||
setShowSnippetLibrary,
|
||||
setShowSecurityDialog,
|
||||
handleAddScript,
|
||||
handleDeleteScript,
|
||||
handleUpdateScript,
|
||||
handleAddParameter,
|
||||
handleDeleteParameter,
|
||||
handleUpdateParameter,
|
||||
handleTestInputChange,
|
||||
handleScanCode,
|
||||
handleTestScript,
|
||||
handleProceedWithExecution,
|
||||
}
|
||||
}
|
||||
42
frontends/nextjs/src/components/editors/lua/types.ts
Normal file
42
frontends/nextjs/src/components/editors/lua/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type LuaBlockType =
|
||||
| 'log'
|
||||
| 'set_variable'
|
||||
| 'if'
|
||||
| 'if_else'
|
||||
| 'repeat'
|
||||
| 'return'
|
||||
| 'call'
|
||||
| 'comment'
|
||||
|
||||
export type BlockSlot = 'root' | 'children' | 'elseChildren'
|
||||
|
||||
export type BlockCategory = 'Basics' | 'Logic' | 'Loops' | 'Data' | 'Functions'
|
||||
|
||||
export type BlockFieldType = 'text' | 'number' | 'select'
|
||||
|
||||
export interface BlockFieldDefinition {
|
||||
name: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
type?: BlockFieldType
|
||||
defaultValue: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
export interface BlockDefinition {
|
||||
type: LuaBlockType
|
||||
label: string
|
||||
description: string
|
||||
category: BlockCategory
|
||||
fields: BlockFieldDefinition[]
|
||||
hasChildren?: boolean
|
||||
hasElseChildren?: boolean
|
||||
}
|
||||
|
||||
export interface LuaBlock {
|
||||
id: string
|
||||
type: LuaBlockType
|
||||
fields: Record<string, string>
|
||||
children?: LuaBlock[]
|
||||
elseChildren?: LuaBlock[]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { Monaco } from '@monaco-editor/react'
|
||||
|
||||
export const useLuaDiagnostics = (monaco: Monaco | null) =>
|
||||
useEffect(() => {
|
||||
if (!monaco) return
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('lua', {
|
||||
provideCompletionItems: (model, position) => {
|
||||
const word = model.getWordUntilPosition(position)
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
}
|
||||
|
||||
const suggestions: any[] = [
|
||||
{
|
||||
label: 'context.data',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.data',
|
||||
documentation: 'Access input parameters passed to the script',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'context.user',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.user',
|
||||
documentation: 'Current user information (username, role, etc.)',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'context.kv',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'context.kv',
|
||||
documentation: 'Key-value storage interface',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'context.log',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'context.log(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Log a message to the output console',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'log',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'log(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Log a message (shortcut for context.log)',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'print',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'print(${1:message})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Print a message to output',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'return',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: 'return ${1:result}',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Return a value from the script',
|
||||
range,
|
||||
},
|
||||
]
|
||||
|
||||
return { suggestions }
|
||||
},
|
||||
})
|
||||
|
||||
monaco.languages.setLanguageConfiguration('lua', {
|
||||
comments: {
|
||||
lineComment: '--',
|
||||
blockComment: ['--[[', ']]'],
|
||||
},
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
})
|
||||
}, [monaco])
|
||||
@@ -28,9 +28,9 @@ import { toast } from 'sonner'
|
||||
import { Level5Header } from '../../level5/header/Level5Header'
|
||||
import { TenantsTab } from '../../level5/tabs/TenantsTab'
|
||||
import { GodUsersTab } from '../../level5/tabs/GodUsersTab'
|
||||
import { PowerTransferTab } from '../../level5/tabs/PowerTransferTab'
|
||||
import { PowerTransferTab } from '../../level5/tabs/power-transfer/PowerTransferTab'
|
||||
import { PreviewTab } from '../../level5/tabs/PreviewTab'
|
||||
import { ErrorLogsTab } from '../../level5/tabs/ErrorLogsTab'
|
||||
import { ErrorLogsTab } from '../../level5/tabs/error-logs/ErrorLogsTab'
|
||||
import { ScreenshotAnalyzer } from '../../misc/demos/ScreenshotAnalyzer'
|
||||
import { NerdModeIDE } from '../../misc/NerdModeIDE'
|
||||
import type { User, AppLevel, Tenant } from '@/lib/level-types'
|
||||
|
||||
@@ -16,7 +16,7 @@ import { QuickGuide } from '@/components/QuickGuide'
|
||||
import { PackageManager } from '@/components/PackageManager'
|
||||
import { ThemeEditor } from '@/components/ThemeEditor'
|
||||
import { SMTPConfigEditor } from '@/components/SMTPConfigEditor'
|
||||
import { ErrorLogsTab } from '@/components/level5/tabs/ErrorLogsTab'
|
||||
import { ErrorLogsTab } from '@/components/level5/tabs/error-logs/ErrorLogsTab'
|
||||
import type { AppConfiguration, User } from '@/lib/level-types'
|
||||
|
||||
interface Level4TabsProps {
|
||||
|
||||
@@ -1,801 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui'
|
||||
import { Warning, CheckCircle, Info, Trash, Broom } from '@phosphor-icons/react'
|
||||
import { Database } from '@/lib/database'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { toast } from 'sonner'
|
||||
import { clearErrorLogs, deleteErrorLog, markErrorResolved } from './error-logs/errorLogActions'
|
||||
import { ErrorLogControls } from './error-logs/ErrorLogControls'
|
||||
import { ErrorLogList } from './error-logs/ErrorLogList'
|
||||
import { ErrorLogStats } from './error-logs/ErrorLogStats'
|
||||
import { ClearLogsDialog } from './error-logs/ClearLogsDialog'
|
||||
import { filterLogs, useErrorLogFilters } from './error-logs/useErrorLogFilters'
|
||||
import { useErrorLogs } from './error-logs/useErrorLogs'
|
||||
|
||||
interface ErrorLogsTabProps {
|
||||
user?: User // Optional: If provided, filters logs by user's tenantId (for God tier)
|
||||
user?: User
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function ErrorLogsTab({ user }: ErrorLogsTabProps) {
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filterLevel, setFilterLevel] = useState<string>('all')
|
||||
const [filterResolved, setFilterResolved] = useState<string>('all')
|
||||
const { logs, loading, stats, reload, isSuperGod } = useErrorLogs(user)
|
||||
const { filters, setFilterLevel, setFilterResolution } = useErrorLogFilters()
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [clearOnlyResolved, setClearOnlyResolved] = useState(false)
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
info: 0,
|
||||
resolved: 0,
|
||||
unresolved: 0,
|
||||
})
|
||||
|
||||
// Determine access level based on user role
|
||||
const isSuperGod = user?.role === 'supergod'
|
||||
const tenantId = user?.tenantId
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// SuperGod sees all logs, God sees only their tenant's logs
|
||||
const options = isSuperGod ? {} : { tenantId }
|
||||
const data = await Database.getErrorLogs(options)
|
||||
setLogs(data)
|
||||
calculateStats(data)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load error logs')
|
||||
console.error('Error loading logs:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const calculateStats = (logs: ErrorLog[]) => {
|
||||
setStats({
|
||||
total: logs.length,
|
||||
errors: logs.filter(l => l.level === 'error').length,
|
||||
warnings: logs.filter(l => l.level === 'warning').length,
|
||||
info: logs.filter(l => l.level === 'info').length,
|
||||
resolved: logs.filter(l => l.resolved).length,
|
||||
unresolved: logs.filter(l => !l.resolved).length,
|
||||
})
|
||||
}
|
||||
const filteredLogs = filterLogs(logs, filters)
|
||||
|
||||
const handleMarkResolved = async (id: string) => {
|
||||
try {
|
||||
await Database.updateErrorLog(id, {
|
||||
resolved: true,
|
||||
resolvedAt: Date.now(),
|
||||
resolvedBy: user?.username || 'admin',
|
||||
})
|
||||
await loadLogs()
|
||||
toast.success('Error log marked as resolved')
|
||||
} catch (err) {
|
||||
toast.error('Failed to update error log')
|
||||
}
|
||||
await markErrorResolved(id, reload, user)
|
||||
}
|
||||
|
||||
const handleDeleteLog = async (id: string) => {
|
||||
try {
|
||||
await Database.deleteErrorLog(id)
|
||||
await loadLogs()
|
||||
toast.success('Error log deleted')
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete error log')
|
||||
}
|
||||
await deleteErrorLog(id, reload)
|
||||
}
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
try {
|
||||
const count = await Database.clearErrorLogs(clearOnlyResolved)
|
||||
await loadLogs()
|
||||
toast.success(`Cleared ${count} error log${count !== 1 ? 's' : ''}`)
|
||||
setShowClearDialog(false)
|
||||
} catch (err) {
|
||||
toast.error('Failed to clear error logs')
|
||||
}
|
||||
await clearErrorLogs(clearOnlyResolved, reload, () => setShowClearDialog(false))
|
||||
}
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return <Warning className="w-5 h-5" weight="fill" />
|
||||
case 'warning':
|
||||
return <Warning className="w-5 h-5" />
|
||||
case 'info':
|
||||
return <Info className="w-5 h-5" />
|
||||
default:
|
||||
return <Info className="w-5 h-5" />
|
||||
}
|
||||
const openClearDialog = (onlyResolved: boolean) => {
|
||||
setClearOnlyResolved(onlyResolved)
|
||||
setShowClearDialog(true)
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
case 'warning':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
||||
case 'info':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400 border-gray-500/50'
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
if (filterLevel !== 'all' && log.level !== filterLevel) return false
|
||||
if (filterResolved === 'resolved' && !log.resolved) return false
|
||||
if (filterResolved === 'unresolved' && log.resolved) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const scopeDescription = isSuperGod
|
||||
? 'All error logs across all tenants'
|
||||
: `Error logs for your tenant only`
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Total</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-400">{stats.errors}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Warnings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-400">{stats.warnings}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.info}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Resolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-400">{stats.resolved}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Unresolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-400">{stats.unresolved}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<ErrorLogStats stats={stats} />
|
||||
|
||||
<Card className="bg-black/40 border-white/10 text-white">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>System Error Logs</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
{scopeDescription}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadLogs} disabled={loading} size="sm" variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
{isSuperGod && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setClearOnlyResolved(false)
|
||||
setShowClearDialog(true)
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setClearOnlyResolved(true)
|
||||
setShowClearDialog(true)
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear Resolved
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Select value={filterLevel} onValueChange={setFilterLevel}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="warning">Warnings</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterResolved} onValueChange={setFilterResolved}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="unresolved">Unresolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ErrorLogControls
|
||||
filterLevel={filters.level}
|
||||
filterResolution={filters.resolution}
|
||||
setFilterLevel={setFilterLevel}
|
||||
setFilterResolution={setFilterResolution}
|
||||
onRefresh={reload}
|
||||
loading={loading}
|
||||
user={user}
|
||||
onRequestClear={openClearDialog}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-3">
|
||||
{filteredLogs.length === 0 && !loading && (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
No error logs found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLogs.map((log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
className={`bg-white/5 border-white/10 ${log.resolved ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded ${getLevelColor(log.level)}`}>
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
{log.resolved && (
|
||||
<Badge variant="outline" className="bg-green-500/20 text-green-400 border-green-500/50">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Resolved
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</span>
|
||||
{isSuperGod && log.tenantId && (
|
||||
<Badge variant="outline" className="bg-purple-500/20 text-purple-400 border-purple-500/50">
|
||||
Tenant: {log.tenantId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white font-medium">{log.message}</p>
|
||||
{log.source && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Source: {log.source}
|
||||
</p>
|
||||
)}
|
||||
{log.username && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
User: {log.username} {log.userId && `(${log.userId})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.stack && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{log.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.context && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">
|
||||
Context
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(JSON.parse(log.context), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.resolved && log.resolvedAt && (
|
||||
<p className="text-xs text-green-400">
|
||||
Resolved on {new Date(log.resolvedAt).toLocaleString()}
|
||||
{log.resolvedBy && ` by ${log.resolvedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!log.resolved && (
|
||||
<Button
|
||||
onClick={() => handleMarkResolved(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
{isSuperGod && (
|
||||
<Button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ErrorLogList
|
||||
logs={filteredLogs}
|
||||
loading={loading}
|
||||
onResolve={handleMarkResolved}
|
||||
onDelete={handleDeleteLog}
|
||||
user={user}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isSuperGod && (
|
||||
<AlertDialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<AlertDialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-amber-300">
|
||||
<Warning className="w-6 h-6" weight="fill" />
|
||||
Confirm Clear Error Logs
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
{clearOnlyResolved
|
||||
? 'This will permanently delete all resolved error logs. This action cannot be undone.'
|
||||
: 'This will permanently delete ALL error logs. This action cannot be undone.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearLogs}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700"
|
||||
>
|
||||
Clear Logs
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ClearLogsDialog
|
||||
open={showClearDialog}
|
||||
onOpenChange={setShowClearDialog}
|
||||
clearOnlyResolved={clearOnlyResolved}
|
||||
onConfirm={handleClearLogs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filterLevel, setFilterLevel] = useState<string>('all')
|
||||
const [filterResolved, setFilterResolved] = useState<string>('all')
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [clearOnlyResolved, setClearOnlyResolved] = useState(false)
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
info: 0,
|
||||
resolved: 0,
|
||||
unresolved: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await Database.getErrorLogs()
|
||||
setLogs(data)
|
||||
calculateStats(data)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load error logs')
|
||||
console.error('Error loading logs:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const calculateStats = (logs: ErrorLog[]) => {
|
||||
setStats({
|
||||
total: logs.length,
|
||||
errors: logs.filter(l => l.level === 'error').length,
|
||||
warnings: logs.filter(l => l.level === 'warning').length,
|
||||
info: logs.filter(l => l.level === 'info').length,
|
||||
resolved: logs.filter(l => l.resolved).length,
|
||||
unresolved: logs.filter(l => !l.resolved).length,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMarkResolved = async (id: string) => {
|
||||
try {
|
||||
await Database.updateErrorLog(id, {
|
||||
resolved: true,
|
||||
resolvedAt: Date.now(),
|
||||
resolvedBy: 'supergod',
|
||||
})
|
||||
await loadLogs()
|
||||
toast.success('Error log marked as resolved')
|
||||
} catch (error) {
|
||||
toast.error('Failed to update error log')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLog = async (id: string) => {
|
||||
try {
|
||||
await Database.deleteErrorLog(id)
|
||||
await loadLogs()
|
||||
toast.success('Error log deleted')
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete error log')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
try {
|
||||
const count = await Database.clearErrorLogs(clearOnlyResolved)
|
||||
await loadLogs()
|
||||
toast.success(`Cleared ${count} error log${count !== 1 ? 's' : ''}`)
|
||||
setShowClearDialog(false)
|
||||
} catch (error) {
|
||||
toast.error('Failed to clear error logs')
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return <Warning className="w-5 h-5" weight="fill" />
|
||||
case 'warning':
|
||||
return <Warning className="w-5 h-5" />
|
||||
case 'info':
|
||||
return <Info className="w-5 h-5" />
|
||||
default:
|
||||
return <Info className="w-5 h-5" />
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
case 'warning':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
||||
case 'info':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400 border-gray-500/50'
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
if (filterLevel !== 'all' && log.level !== filterLevel) return false
|
||||
if (filterResolved === 'resolved' && !log.resolved) return false
|
||||
if (filterResolved === 'unresolved' && log.resolved) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Total</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-400">{stats.errors}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Warnings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-400">{stats.warnings}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.info}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Resolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-400">{stats.resolved}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Unresolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-400">{stats.unresolved}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-black/40 border-white/10 text-white">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>System Error Logs</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Track and manage system errors, warnings, and info messages
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadLogs} disabled={loading} size="sm" variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setClearOnlyResolved(false)
|
||||
setShowClearDialog(true)
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setClearOnlyResolved(true)
|
||||
setShowClearDialog(true)
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear Resolved
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Select value={filterLevel} onValueChange={setFilterLevel}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="warning">Warnings</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterResolved} onValueChange={setFilterResolved}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="unresolved">Unresolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-3">
|
||||
{filteredLogs.length === 0 && !loading && (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
No error logs found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLogs.map((log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
className={`bg-white/5 border-white/10 ${log.resolved ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded ${getLevelColor(log.level)}`}>
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
{log.resolved && (
|
||||
<Badge variant="outline" className="bg-green-500/20 text-green-400 border-green-500/50">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Resolved
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white font-medium">{log.message}</p>
|
||||
{log.source && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Source: {log.source}
|
||||
</p>
|
||||
)}
|
||||
{log.username && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
User: {log.username} {log.userId && `(${log.userId})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.stack && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{log.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.context && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">
|
||||
Context
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(JSON.parse(log.context), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.resolved && log.resolvedAt && (
|
||||
<p className="text-xs text-green-400">
|
||||
Resolved on {new Date(log.resolvedAt).toLocaleString()}
|
||||
{log.resolvedBy && ` by ${log.resolvedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!log.resolved && (
|
||||
<Button
|
||||
onClick={() => handleMarkResolved(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<AlertDialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-amber-300">
|
||||
<Warning className="w-6 h-6" weight="fill" />
|
||||
Confirm Clear Error Logs
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
{clearOnlyResolved
|
||||
? 'This will permanently delete all resolved error logs. This action cannot be undone.'
|
||||
: 'This will permanently delete ALL error logs. This action cannot be undone.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearLogs}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700"
|
||||
>
|
||||
Clear Logs
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { PowerTransferTab } from '../PowerTransferTab'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
const superGodUser: User = {
|
||||
id: 'user_supergod',
|
||||
username: 'supergod',
|
||||
email: 'supergod@metabuilder.local',
|
||||
role: 'supergod',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
const targetUser: User = {
|
||||
id: 'user_target',
|
||||
username: 'target-god',
|
||||
email: 'target@example.com',
|
||||
role: 'god',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
const renderComponent = (overrides?: { onInitiate?: () => void }) => {
|
||||
const onInitiateTransfer = overrides?.onInitiate ?? vi.fn()
|
||||
render(
|
||||
<PowerTransferTab
|
||||
currentUser={superGodUser}
|
||||
allUsers={[superGodUser, targetUser]}
|
||||
onInitiateTransfer={onInitiateTransfer}
|
||||
/>
|
||||
)
|
||||
return { onInitiateTransfer }
|
||||
}
|
||||
|
||||
const mockFetch = (payload: any) => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => payload,
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('PowerTransferTab', () => {
|
||||
it('loads transfer history and shows target records', async () => {
|
||||
const requestPayload = {
|
||||
requests: [
|
||||
{
|
||||
id: 'req1',
|
||||
fromUserId: superGodUser.id,
|
||||
toUserId: targetUser.id,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 100000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const fetchMock = mockFetch(requestPayload)
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
|
||||
await screen.findByText(/Recent transfers/i)
|
||||
expect(screen.getByText(/Transfer to target-god/i)).toBeDefined()
|
||||
expect(screen.getByText(/Requested by supergod/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('fires onInitiateTransfer when a user card is clicked', async () => {
|
||||
mockFetch({ requests: [] })
|
||||
|
||||
const { onInitiateTransfer } = renderComponent()
|
||||
|
||||
await screen.findByText(/Select User/i)
|
||||
|
||||
fireEvent.click(screen.getByText('target-god'))
|
||||
const actionButton = screen.getByRole('button', { name: /Initiate Power Transfer/i })
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
expect(onInitiateTransfer).toHaveBeenCalledWith(targetUser.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui'
|
||||
import { Warning } from '@phosphor-icons/react'
|
||||
|
||||
interface ClearLogsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
clearOnlyResolved: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ClearLogsDialog({ open, onOpenChange, clearOnlyResolved, onConfirm }: ClearLogsDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-amber-300">
|
||||
<Warning className="w-6 h-6" weight="fill" />
|
||||
Confirm Clear Error Logs
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
{clearOnlyResolved
|
||||
? 'This will permanently delete all resolved error logs. This action cannot be undone.'
|
||||
: 'This will permanently delete ALL error logs. This action cannot be undone.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 text-white hover:bg-white/10">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700"
|
||||
>
|
||||
Clear Logs
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Badge, Button, CardDescription, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import type { ErrorLevelFilter, ResolutionFilter } from './useErrorLogFilters'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { Broom } from '@phosphor-icons/react'
|
||||
|
||||
interface ErrorLogControlsProps {
|
||||
filterLevel: ErrorLevelFilter
|
||||
filterResolution: ResolutionFilter
|
||||
setFilterLevel: (value: ErrorLevelFilter) => void
|
||||
setFilterResolution: (value: ResolutionFilter) => void
|
||||
onRefresh: () => void
|
||||
loading: boolean
|
||||
user?: User
|
||||
onRequestClear: (clearOnlyResolved: boolean) => void
|
||||
}
|
||||
|
||||
export function ErrorLogControls({
|
||||
filterLevel,
|
||||
filterResolution,
|
||||
setFilterLevel,
|
||||
setFilterResolution,
|
||||
onRefresh,
|
||||
loading,
|
||||
user,
|
||||
onRequestClear,
|
||||
}: ErrorLogControlsProps) {
|
||||
const isSuperGod = user?.role === 'supergod'
|
||||
const scopeDescription = isSuperGod
|
||||
? 'All error logs across all tenants'
|
||||
: 'Error logs for your tenant only'
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>System Error Logs</CardTitle>
|
||||
<CardDescription className="text-gray-400 flex items-center gap-2">
|
||||
{scopeDescription}
|
||||
{user?.tenantId && !isSuperGod && (
|
||||
<Badge variant="outline" className="bg-purple-500/20 text-purple-400 border-purple-500/50">
|
||||
Tenant: {user.tenantId}
|
||||
</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onRefresh} disabled={loading} size="sm" variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
{isSuperGod && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onRequestClear(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onRequestClear(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear Resolved
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterLevel} onValueChange={setFilterLevel}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="warning">Warnings</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterResolution} onValueChange={setFilterResolution}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="unresolved">Unresolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
|
||||
interface ErrorLogFiltersProps {
|
||||
filterLevel: string
|
||||
filterResolved: string
|
||||
onFilterLevelChange: (value: string) => void
|
||||
onFilterResolvedChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function ErrorLogFilters({
|
||||
filterLevel,
|
||||
filterResolved,
|
||||
onFilterLevelChange,
|
||||
onFilterResolvedChange,
|
||||
}: ErrorLogFiltersProps) {
|
||||
return (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Select value={filterLevel} onValueChange={onFilterLevelChange}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="warning">Warnings</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterResolved} onValueChange={onFilterResolvedChange}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="unresolved">Unresolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Badge, Button, Card, CardContent, ScrollArea } from '@/components/ui'
|
||||
import { CheckCircle, Info, Trash, Warning } from '@phosphor-icons/react'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface ErrorLogListProps {
|
||||
logs: ErrorLog[]
|
||||
onResolve: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
loading: boolean
|
||||
user?: User
|
||||
}
|
||||
|
||||
const LEVEL_ICON = {
|
||||
error: <Warning className="w-5 h-5" weight="fill" />,
|
||||
warning: <Warning className="w-5 h-5" />,
|
||||
info: <Info className="w-5 h-5" />,
|
||||
default: <Info className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
error: 'bg-red-500/20 text-red-400 border-red-500/50',
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
default: 'bg-gray-500/20 text-gray-400 border-gray-500/50',
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => LEVEL_COLOR[level] ?? LEVEL_COLOR.default
|
||||
const getLevelIcon = (level: string) => LEVEL_ICON[level as keyof typeof LEVEL_ICON] ?? LEVEL_ICON.default
|
||||
|
||||
export function ErrorLogList({ logs, onResolve, onDelete, loading, user }: ErrorLogListProps) {
|
||||
const isSuperGod = user?.role === 'supergod'
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-3">
|
||||
{logs.length === 0 && !loading && (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
No error logs found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.map((log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
className={`bg-white/5 border-white/10 ${log.resolved ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded ${getLevelColor(log.level)}`}>
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
{log.resolved && (
|
||||
<Badge variant="outline" className="bg-green-500/20 text-green-400 border-green-500/50">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Resolved
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</span>
|
||||
{isSuperGod && log.tenantId && (
|
||||
<Badge variant="outline" className="bg-purple-500/20 text-purple-400 border-purple-500/50">
|
||||
Tenant: {log.tenantId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white font-medium">{log.message}</p>
|
||||
{log.source && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Source: {log.source}
|
||||
</p>
|
||||
)}
|
||||
{log.username && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
User: {log.username} {log.userId && `(${log.userId})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.stack && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{log.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.context && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">
|
||||
Context
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(JSON.parse(log.context), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.resolved && log.resolvedAt && (
|
||||
<p className="text-xs text-green-400">
|
||||
Resolved on {new Date(log.resolvedAt).toLocaleString()}
|
||||
{log.resolvedBy && ` by ${log.resolvedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!log.resolved && (
|
||||
<Button
|
||||
onClick={() => onResolve(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
{isSuperGod && (
|
||||
<Button
|
||||
onClick={() => onDelete(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
import type { ErrorLogStats } from './useErrorLogs'
|
||||
|
||||
const STAT_CONFIG: Array<{ key: keyof ErrorLogStats; label: string; color: string }> = [
|
||||
{ key: 'total', label: 'Total', color: 'text-white' },
|
||||
{ key: 'errors', label: 'Errors', color: 'text-red-400' },
|
||||
{ key: 'warnings', label: 'Warnings', color: 'text-yellow-400' },
|
||||
{ key: 'info', label: 'Info', color: 'text-blue-400' },
|
||||
{ key: 'resolved', label: 'Resolved', color: 'text-green-400' },
|
||||
{ key: 'unresolved', label: 'Unresolved', color: 'text-orange-400' },
|
||||
]
|
||||
|
||||
export function ErrorLogStats({ stats }: { stats: ErrorLogStats }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{STAT_CONFIG.map(({ key, label, color }) => (
|
||||
<Card key={key} className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${color}`}>{stats[key]}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { Warning } from '@phosphor-icons/react'
|
||||
|
||||
interface ErrorLogsErrorStateProps {
|
||||
message: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function ErrorLogsErrorState({ message, onRetry }: ErrorLogsErrorStateProps) {
|
||||
return (
|
||||
<Card className="bg-red-500/10 border-red-500/40 text-white">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Warning className="w-5 h-5 text-red-300" weight="fill" />
|
||||
<div>
|
||||
<p className="font-semibold">Unable to load error logs</p>
|
||||
<p className="text-sm text-red-200/80">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="border-white/20" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
interface ErrorLogsPaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function ErrorLogsPagination({ page, totalPages, onPageChange }: ErrorLogsPaginationProps) {
|
||||
const canGoBack = page > 1
|
||||
const canGoForward = page < totalPages
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { Broom, Warning } from '@phosphor-icons/react'
|
||||
import { ErrorLogTable } from './modules/ErrorLogTable'
|
||||
import { StatsGrid } from './modules/StatsGrid'
|
||||
import { useErrorLogActions } from './modules/useErrorLogActions'
|
||||
import { useErrorLogData } from './modules/useErrorLogData'
|
||||
import { useErrorLogFilters } from './modules/useErrorLogFilters'
|
||||
interface ErrorLogsTabProps {
|
||||
user?: User
|
||||
}
|
||||
|
||||
export const ErrorLogsTab = ({ user }: ErrorLogsTabProps) => {
|
||||
const { logs, stats, loading, reload, isSuperGod, scopeDescription } = useErrorLogData(user)
|
||||
const { filterLevel, filterResolved, setFilterLevel, setFilterResolved, filteredLogs } = useErrorLogFilters(logs)
|
||||
const {
|
||||
clearOnlyResolved,
|
||||
handleClearLogs,
|
||||
handleDeleteLog,
|
||||
handleMarkResolved,
|
||||
openClearDialog,
|
||||
setShowClearDialog,
|
||||
showClearDialog,
|
||||
} = useErrorLogActions({ onRefresh: reload, username: user?.username })
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatsGrid stats={stats} />
|
||||
<Card className="bg-black/40 border-white/10 text-white">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>System Error Logs</CardTitle>
|
||||
<CardDescription className="text-gray-400">{scopeDescription}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={reload} disabled={loading} size="sm" variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
{isSuperGod && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => openClearDialog(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openClearDialog(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<Broom className="w-4 h-4 mr-2" />
|
||||
Clear Resolved
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Select value={filterLevel} onValueChange={setFilterLevel}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="warning">Warnings</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterResolved} onValueChange={setFilterResolved}>
|
||||
<SelectTrigger className="w-[180px] bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-white/10 text-white">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="unresolved">Unresolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ErrorLogTable
|
||||
logs={filteredLogs}
|
||||
isSuperGod={isSuperGod}
|
||||
loading={loading}
|
||||
onResolve={handleMarkResolved}
|
||||
onDelete={handleDeleteLog}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isSuperGod && (
|
||||
<AlertDialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<AlertDialogContent className="bg-slate-900 border-white/10 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-amber-300">
|
||||
<Warning className="w-6 h-6" weight="fill" />
|
||||
Confirm Clear Error Logs
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
{clearOnlyResolved
|
||||
? 'This will permanently delete all resolved error logs. This action cannot be undone.'
|
||||
: 'This will permanently delete ALL error logs. This action cannot be undone.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-white/20 text-white hover:bg-white/10">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearLogs}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700"
|
||||
>
|
||||
Clear Logs
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Database } from '@/lib/database'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export async function markErrorResolved(id: string, reload: () => Promise<void>, user?: User) {
|
||||
try {
|
||||
await Database.updateErrorLog(id, {
|
||||
resolved: true,
|
||||
resolvedAt: Date.now(),
|
||||
resolvedBy: user?.username || 'admin',
|
||||
})
|
||||
await reload()
|
||||
toast.success('Error log marked as resolved')
|
||||
} catch (error) {
|
||||
toast.error('Failed to update error log')
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteErrorLog(id: string, reload: () => Promise<void>) {
|
||||
try {
|
||||
await Database.deleteErrorLog(id)
|
||||
await reload()
|
||||
toast.success('Error log deleted')
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete error log')
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearErrorLogs(
|
||||
clearOnlyResolved: boolean,
|
||||
reload: () => Promise<void>,
|
||||
onCleared?: () => void
|
||||
) {
|
||||
try {
|
||||
const count = await Database.clearErrorLogs(clearOnlyResolved)
|
||||
await reload()
|
||||
toast.success(`Cleared ${count} error log${count !== 1 ? 's' : ''}`)
|
||||
onCleared?.()
|
||||
} catch (error) {
|
||||
toast.error('Failed to clear error logs')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import { calculateStats, filterLogs } from './errorLogUtils'
|
||||
|
||||
const sampleLogs: ErrorLog[] = [
|
||||
{ id: '1', message: 'Error', level: 'error', timestamp: Date.now(), resolved: false },
|
||||
{ id: '2', message: 'Warning', level: 'warning', timestamp: Date.now(), resolved: true },
|
||||
{ id: '3', message: 'Info', level: 'info', timestamp: Date.now(), resolved: false },
|
||||
]
|
||||
|
||||
describe('calculateStats', () => {
|
||||
it('calculates counts by level and resolution', () => {
|
||||
const stats = calculateStats(sampleLogs)
|
||||
|
||||
expect(stats.total).toBe(3)
|
||||
expect(stats.errors).toBe(1)
|
||||
expect(stats.warnings).toBe(1)
|
||||
expect(stats.info).toBe(1)
|
||||
expect(stats.resolved).toBe(1)
|
||||
expect(stats.unresolved).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterLogs', () => {
|
||||
it('filters by level', () => {
|
||||
const filtered = filterLogs(sampleLogs, { filterLevel: 'error', filterResolved: 'all' })
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].level).toBe('error')
|
||||
})
|
||||
|
||||
it('filters by resolved state', () => {
|
||||
const filtered = filterLogs(sampleLogs, { filterLevel: 'all', filterResolved: 'resolved' })
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('returns all when filters are set to all', () => {
|
||||
const filtered = filterLogs(sampleLogs, { filterLevel: 'all', filterResolved: 'all' })
|
||||
expect(filtered).toHaveLength(sampleLogs.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
|
||||
export interface ErrorLogStats {
|
||||
total: number
|
||||
errors: number
|
||||
warnings: number
|
||||
info: number
|
||||
resolved: number
|
||||
unresolved: number
|
||||
}
|
||||
|
||||
export interface ErrorLogFilters {
|
||||
filterLevel: string
|
||||
filterResolved: string
|
||||
}
|
||||
|
||||
export const emptyStats: ErrorLogStats = {
|
||||
total: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
info: 0,
|
||||
resolved: 0,
|
||||
unresolved: 0,
|
||||
}
|
||||
|
||||
export function calculateStats(logs: ErrorLog[]): ErrorLogStats {
|
||||
return {
|
||||
total: logs.length,
|
||||
errors: logs.filter((log) => log.level === 'error').length,
|
||||
warnings: logs.filter((log) => log.level === 'warning').length,
|
||||
info: logs.filter((log) => log.level === 'info').length,
|
||||
resolved: logs.filter((log) => log.resolved).length,
|
||||
unresolved: logs.filter((log) => !log.resolved).length,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterLogs(logs: ErrorLog[], filters: ErrorLogFilters): ErrorLog[] {
|
||||
return logs.filter((log) => {
|
||||
if (filters.filterLevel !== 'all' && log.level !== filters.filterLevel) return false
|
||||
if (filters.filterResolved === 'resolved' && !log.resolved) return false
|
||||
if (filters.filterResolved === 'unresolved' && log.resolved) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const formatLogContext = (context?: string) => {
|
||||
if (!context) return null
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(context), null, 2)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse log context', error)
|
||||
return context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Info, Warning } from '@phosphor-icons/react'
|
||||
|
||||
export const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return <Warning className="w-5 h-5" weight="fill" />
|
||||
case 'warning':
|
||||
return <Warning className="w-5 h-5" />
|
||||
case 'info':
|
||||
return <Info className="w-5 h-5" />
|
||||
default:
|
||||
return <Info className="w-5 h-5" />
|
||||
}
|
||||
}
|
||||
|
||||
export const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
case 'warning':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
||||
case 'info':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400 border-gray-500/50'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Badge, Button, Card, CardContent, ScrollArea } from '@/components/ui'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import { CheckCircle, Trash } from '@phosphor-icons/react'
|
||||
|
||||
import { formatLogContext } from '../helpers/context'
|
||||
import { getLevelColor, getLevelIcon } from '../helpers/levelStyles'
|
||||
|
||||
interface ErrorLogTableProps {
|
||||
logs: ErrorLog[]
|
||||
isSuperGod: boolean
|
||||
loading: boolean
|
||||
onResolve: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const ErrorLogTable = ({ logs, isSuperGod, loading, onResolve, onDelete }: ErrorLogTableProps) => (
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-3">
|
||||
{logs.length === 0 && !loading && (
|
||||
<div className="py-12 text-center text-gray-400">No error logs found</div>
|
||||
)}
|
||||
|
||||
{logs.map((log) => {
|
||||
const context = formatLogContext(log.context)
|
||||
|
||||
return (
|
||||
<Card key={log.id} className={`bg-white/5 border-white/10 ${log.resolved ? 'opacity-60' : ''}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded ${getLevelColor(log.level)}`}>{getLevelIcon(log.level)}</div>
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
{log.resolved && (
|
||||
<Badge variant="outline" className="bg-green-500/20 text-green-400 border-green-500/50">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Resolved
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">{new Date(log.timestamp).toLocaleString()}</span>
|
||||
{isSuperGod && log.tenantId && (
|
||||
<Badge variant="outline" className="bg-purple-500/20 text-purple-400 border-purple-500/50">
|
||||
Tenant: {log.tenantId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white font-medium">{log.message}</p>
|
||||
{log.source && <p className="text-xs text-gray-400 mt-1">Source: {log.source}</p>}
|
||||
{log.username && (
|
||||
<p className="text-xs text-gray-400 mt-1">User: {log.username} {log.userId && `(${log.userId})`}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.stack && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">Stack trace</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">{log.stack}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{context && (
|
||||
<details className="text-xs text-gray-400 bg-black/40 p-2 rounded">
|
||||
<summary className="cursor-pointer hover:text-white">Context</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">{context}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{log.resolved && log.resolvedAt && (
|
||||
<p className="text-xs text-green-400">
|
||||
Resolved on {new Date(log.resolvedAt).toLocaleString()}
|
||||
{log.resolvedBy && ` by ${log.resolvedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!log.resolved && (
|
||||
<Button
|
||||
onClick={() => onResolve(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => onDelete(log.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
total: number
|
||||
errors: number
|
||||
warnings: number
|
||||
info: number
|
||||
resolved: number
|
||||
unresolved: number
|
||||
}
|
||||
}
|
||||
|
||||
const cardConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-white' },
|
||||
{ key: 'errors', label: 'Errors', color: 'text-red-400' },
|
||||
{ key: 'warnings', label: 'Warnings', color: 'text-yellow-400' },
|
||||
{ key: 'info', label: 'Info', color: 'text-blue-400' },
|
||||
{ key: 'resolved', label: 'Resolved', color: 'text-green-400' },
|
||||
{ key: 'unresolved', label: 'Unresolved', color: 'text-orange-400' },
|
||||
] as const
|
||||
|
||||
export const StatsGrid = ({ stats }: StatsGridProps) => (
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{cardConfig.map(({ key, label, color }) => (
|
||||
<Card key={key} className="bg-black/40 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${color}`}>{stats[key]}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Database } from '@/lib/database'
|
||||
|
||||
interface ErrorLogActionsConfig {
|
||||
onRefresh: () => Promise<void> | void
|
||||
username?: string
|
||||
}
|
||||
|
||||
export const useErrorLogActions = ({ onRefresh, username }: ErrorLogActionsConfig) => {
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [clearOnlyResolved, setClearOnlyResolved] = useState(false)
|
||||
|
||||
const handleMarkResolved = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await Database.updateErrorLog(id, {
|
||||
resolved: true,
|
||||
resolvedAt: Date.now(),
|
||||
resolvedBy: username || 'admin',
|
||||
})
|
||||
await onRefresh()
|
||||
toast.success('Error log marked as resolved')
|
||||
} catch (error) {
|
||||
toast.error('Failed to update error log')
|
||||
}
|
||||
},
|
||||
[onRefresh, username],
|
||||
)
|
||||
|
||||
const handleDeleteLog = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await Database.deleteErrorLog(id)
|
||||
await onRefresh()
|
||||
toast.success('Error log deleted')
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete error log')
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
)
|
||||
|
||||
const handleClearLogs = useCallback(async () => {
|
||||
try {
|
||||
const count = await Database.clearErrorLogs(clearOnlyResolved)
|
||||
await onRefresh()
|
||||
toast.success(`Cleared ${count} error log${count !== 1 ? 's' : ''}`)
|
||||
setShowClearDialog(false)
|
||||
} catch (error) {
|
||||
toast.error('Failed to clear error logs')
|
||||
}
|
||||
}, [clearOnlyResolved, onRefresh])
|
||||
|
||||
const openClearDialog = (onlyResolved: boolean) => {
|
||||
setClearOnlyResolved(onlyResolved)
|
||||
setShowClearDialog(true)
|
||||
}
|
||||
|
||||
return {
|
||||
clearOnlyResolved,
|
||||
handleClearLogs,
|
||||
handleDeleteLog,
|
||||
handleMarkResolved,
|
||||
openClearDialog,
|
||||
setShowClearDialog,
|
||||
showClearDialog,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Database } from '@/lib/database'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
interface ErrorLogStats {
|
||||
total: number
|
||||
errors: number
|
||||
warnings: number
|
||||
info: number
|
||||
resolved: number
|
||||
unresolved: number
|
||||
}
|
||||
|
||||
const buildStats = (logs: ErrorLog[]): ErrorLogStats => ({
|
||||
total: logs.length,
|
||||
errors: logs.filter((log) => log.level === 'error').length,
|
||||
warnings: logs.filter((log) => log.level === 'warning').length,
|
||||
info: logs.filter((log) => log.level === 'info').length,
|
||||
resolved: logs.filter((log) => log.resolved).length,
|
||||
unresolved: logs.filter((log) => !log.resolved).length,
|
||||
})
|
||||
|
||||
export const useErrorLogData = (user?: User) => {
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||
const [stats, setStats] = useState<ErrorLogStats>(buildStats([]))
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isSuperGod = user?.role === 'supergod'
|
||||
const tenantId = user?.tenantId
|
||||
const scopeDescription = isSuperGod
|
||||
? 'All error logs across all tenants'
|
||||
: 'Error logs for your tenant only'
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const options = isSuperGod ? {} : { tenantId }
|
||||
const data = await Database.getErrorLogs(options)
|
||||
setLogs(data)
|
||||
setStats(buildStats(data))
|
||||
} catch (error) {
|
||||
toast.error('Failed to load error logs')
|
||||
console.error('Error loading logs:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isSuperGod, tenantId])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
return { logs, stats, loading, reload: loadLogs, isSuperGod, scopeDescription }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
|
||||
export const useErrorLogFilters = (logs: ErrorLog[]) => {
|
||||
const [filterLevel, setFilterLevel] = useState('all')
|
||||
const [filterResolved, setFilterResolved] = useState('all')
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
logs.filter((log) => {
|
||||
if (filterLevel !== 'all' && log.level !== filterLevel) return false
|
||||
if (filterResolved === 'resolved' && !log.resolved) return false
|
||||
if (filterResolved === 'unresolved' && log.resolved) return false
|
||||
return true
|
||||
}),
|
||||
[logs, filterLevel, filterResolved],
|
||||
)
|
||||
|
||||
return { filterLevel, filterResolved, setFilterLevel, setFilterResolved, filteredLogs }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
|
||||
export type ErrorLevelFilter = 'all' | 'error' | 'warning' | 'info'
|
||||
export type ResolutionFilter = 'all' | 'resolved' | 'unresolved'
|
||||
|
||||
export interface ErrorLogFilters {
|
||||
level: ErrorLevelFilter
|
||||
resolution: ResolutionFilter
|
||||
}
|
||||
|
||||
interface UseErrorLogFiltersReturn {
|
||||
filters: ErrorLogFilters
|
||||
setFilterLevel: (value: ErrorLevelFilter) => void
|
||||
setFilterResolution: (value: ResolutionFilter) => void
|
||||
}
|
||||
|
||||
export function useErrorLogFilters(): UseErrorLogFiltersReturn {
|
||||
const [filterLevel, setFilterLevel] = useState<ErrorLevelFilter>('all')
|
||||
const [filterResolved, setFilterResolved] = useState<ResolutionFilter>('all')
|
||||
|
||||
return {
|
||||
filters: {
|
||||
level: filterLevel,
|
||||
resolution: filterResolved,
|
||||
},
|
||||
setFilterLevel,
|
||||
setFilterResolution: setFilterResolved,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterLogs(logs: ErrorLog[], filters: ErrorLogFilters): ErrorLog[] {
|
||||
return logs.filter(log => {
|
||||
if (filters.level !== 'all' && log.level !== filters.level) return false
|
||||
if (filters.resolution === 'resolved' && !log.resolved) return false
|
||||
if (filters.resolution === 'unresolved' && log.resolved) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Database } from '@/lib/database'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface ErrorLogStats {
|
||||
total: number
|
||||
errors: number
|
||||
warnings: number
|
||||
info: number
|
||||
resolved: number
|
||||
unresolved: number
|
||||
}
|
||||
|
||||
interface UseErrorLogsReturn {
|
||||
logs: ErrorLog[]
|
||||
loading: boolean
|
||||
stats: ErrorLogStats
|
||||
reload: () => Promise<void>
|
||||
isSuperGod: boolean
|
||||
}
|
||||
|
||||
export function useErrorLogs(user?: User): UseErrorLogsReturn {
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stats, setStats] = useState<ErrorLogStats>({
|
||||
total: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
info: 0,
|
||||
resolved: 0,
|
||||
unresolved: 0,
|
||||
})
|
||||
|
||||
const isSuperGod = user?.role === 'supergod'
|
||||
const tenantId = user?.tenantId
|
||||
|
||||
const calculateStats = useCallback((data: ErrorLog[]) => {
|
||||
setStats({
|
||||
total: data.length,
|
||||
errors: data.filter(l => l.level === 'error').length,
|
||||
warnings: data.filter(l => l.level === 'warning').length,
|
||||
info: data.filter(l => l.level === 'info').length,
|
||||
resolved: data.filter(l => l.resolved).length,
|
||||
unresolved: data.filter(l => !l.resolved).length,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const options = isSuperGod ? {} : { tenantId }
|
||||
const data = await Database.getErrorLogs(options)
|
||||
setLogs(data)
|
||||
calculateStats(data)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load error logs')
|
||||
console.error('Error loading logs:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [calculateStats, isSuperGod, tenantId])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
return {
|
||||
logs,
|
||||
loading,
|
||||
stats,
|
||||
reload: loadLogs,
|
||||
isSuperGod,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Database } from '@/lib/database'
|
||||
import type { ErrorLog } from '@/lib/db/error-logs'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { calculateStats, emptyStats, filterLogs, type ErrorLogStats } from './errorLogUtils'
|
||||
|
||||
export interface UseErrorLogsDataParams {
|
||||
user?: User
|
||||
filters: {
|
||||
filterLevel: string
|
||||
filterResolved: string
|
||||
}
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface UseErrorLogsDataResult {
|
||||
logs: ErrorLog[]
|
||||
filteredLogs: ErrorLog[]
|
||||
displayedLogs: ErrorLog[]
|
||||
stats: ErrorLogStats
|
||||
loading: boolean
|
||||
error: string | null
|
||||
page: number
|
||||
totalPages: number
|
||||
isSuperGod: boolean
|
||||
loadLogs: () => Promise<void>
|
||||
setPage: (page: number) => void
|
||||
handleMarkResolved: (id: string) => Promise<void>
|
||||
handleDeleteLog: (id: string) => Promise<void>
|
||||
handleClearLogs: (clearOnlyResolved: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function useErrorLogsData({ user, filters, pageSize }: UseErrorLogsDataParams): UseErrorLogsDataResult {
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||
const [stats, setStats] = useState(emptyStats)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const isSuperGod = user?.role === 'supergod'
|
||||
const tenantId = user?.tenantId
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const options = isSuperGod ? {} : { tenantId }
|
||||
const data = await Database.getErrorLogs(options)
|
||||
setLogs(data)
|
||||
setStats(calculateStats(data))
|
||||
setPage(1)
|
||||
} catch (err) {
|
||||
console.error('Error loading logs:', err)
|
||||
setError('Failed to load error logs')
|
||||
toast.error('Failed to load error logs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isSuperGod, tenantId])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
const filteredLogs = useMemo(() => filterLogs(logs, filters), [filters, logs])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
|
||||
const displayedLogs = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
const end = start + pageSize
|
||||
return filteredLogs.slice(start, end)
|
||||
}, [currentPage, filteredLogs, pageSize])
|
||||
|
||||
const handleMarkResolved = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await Database.updateErrorLog(id, {
|
||||
resolved: true,
|
||||
resolvedAt: Date.now(),
|
||||
resolvedBy: user?.username || 'admin',
|
||||
})
|
||||
await loadLogs()
|
||||
toast.success('Error log marked as resolved')
|
||||
} catch (err) {
|
||||
console.error('Failed to update error log', err)
|
||||
toast.error('Failed to update error log')
|
||||
}
|
||||
},
|
||||
[loadLogs, user?.username],
|
||||
)
|
||||
|
||||
const handleDeleteLog = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await Database.deleteErrorLog(id)
|
||||
await loadLogs()
|
||||
toast.success('Error log deleted')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete error log', err)
|
||||
toast.error('Failed to delete error log')
|
||||
}
|
||||
},
|
||||
[loadLogs],
|
||||
)
|
||||
|
||||
const handleClearLogs = useCallback(
|
||||
async (clearOnlyResolved: boolean) => {
|
||||
try {
|
||||
const count = await Database.clearErrorLogs(clearOnlyResolved)
|
||||
await loadLogs()
|
||||
toast.success(`Cleared ${count} error log${count !== 1 ? 's' : ''}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to clear error logs', err)
|
||||
toast.error('Failed to clear error logs')
|
||||
}
|
||||
},
|
||||
[loadLogs],
|
||||
)
|
||||
|
||||
return {
|
||||
logs,
|
||||
filteredLogs,
|
||||
displayedLogs,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
isSuperGod: Boolean(isSuperGod),
|
||||
loadLogs,
|
||||
setPage,
|
||||
handleMarkResolved,
|
||||
handleDeleteLog,
|
||||
handleClearLogs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { Alert, AlertDescription } from '@/components/ui'
|
||||
import { Crown, ArrowsLeftRight } from '@phosphor-icons/react'
|
||||
import type { PowerTransferRequest, User } from '@/lib/level-types'
|
||||
import { fetchPowerTransferRequests } from '@/lib/api/power-transfers'
|
||||
|
||||
interface PowerTransferTabProps {
|
||||
currentUser: User
|
||||
allUsers: User[]
|
||||
onInitiateTransfer: (userId: string) => void
|
||||
refreshSignal?: number
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<PowerTransferRequest['status'], 'default' | 'secondary' | 'destructive'> = {
|
||||
accepted: 'default',
|
||||
pending: 'secondary',
|
||||
rejected: 'destructive',
|
||||
}
|
||||
|
||||
export function PowerTransferTab({
|
||||
currentUser,
|
||||
allUsers,
|
||||
onInitiateTransfer,
|
||||
refreshSignal = 0,
|
||||
}: PowerTransferTabProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [requests, setRequests] = useState<PowerTransferRequest[]>([])
|
||||
const [isLoadingRequests, setIsLoadingRequests] = useState(true)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
|
||||
const highlightedUsers = allUsers.filter(
|
||||
(u) => u.id !== currentUser.id && u.role !== 'supergod'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
const reload = async () => {
|
||||
setIsLoadingRequests(true)
|
||||
setRequestError(null)
|
||||
try {
|
||||
const payload = await fetchPowerTransferRequests()
|
||||
if (isActive) {
|
||||
setRequests(payload)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
setRequestError(
|
||||
error instanceof Error ? error.message : 'Unable to load transfer history'
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setIsLoadingRequests(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reload()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [refreshSignal])
|
||||
|
||||
const sortedRequests = [...requests].sort((a, b) => b.createdAt - a.createdAt)
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
const formatExpiry = (expiresAt: number) => {
|
||||
const diff = expiresAt - Date.now()
|
||||
if (diff <= 0) {
|
||||
return 'Expired'
|
||||
}
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const seconds = Math.floor((diff % 60000) / 1000)
|
||||
return `${minutes}m ${seconds}s remaining`
|
||||
}
|
||||
|
||||
const getUserLabel = (userId: string) => {
|
||||
const user = allUsers.find((u) => u.id === userId)
|
||||
return user ? user.username : userId
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-black/40 border-white/10 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle>Transfer Super God Power</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Transfer your Super God privileges to another user. You will be downgraded to God.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
||||
<div className="flex gap-3">
|
||||
<Crown className="w-6 h-6 text-amber-400 flex-shrink-0" weight="fill" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-200 mb-1">Critical Action</h4>
|
||||
<p className="text-sm text-amber-300/80">
|
||||
This action cannot be undone. Only one Super God can exist at a time. After transfer,
|
||||
you will have God-level access only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-white/10" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-white">Select User to Transfer Power To:</h4>
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-2">
|
||||
{highlightedUsers.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedUserId === user.id
|
||||
? 'bg-purple-600/30 border-purple-500'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-white">{user.username}</p>
|
||||
<p className="text-sm text-gray-400">{user.email}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-gray-300 border-gray-500/50">
|
||||
{user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-white">Recent transfers</h4>
|
||||
{isLoadingRequests && (
|
||||
<span className="text-xs text-muted-foreground">Refreshing...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requestError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{requestError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[260px]">
|
||||
<div className="space-y-3 p-2">
|
||||
{sortedRequests.length === 0 && !isLoadingRequests ? (
|
||||
<p className="text-sm text-muted-foreground">No transfer history available.</p>
|
||||
) : (
|
||||
sortedRequests.map((request) => (
|
||||
<Card key={request.id} className="bg-white/5 border-white/10">
|
||||
<CardHeader className="flex items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base text-white">
|
||||
Transfer to {getUserLabel(request.toUserId)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
Requested by {getUserLabel(request.fromUserId)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={STATUS_VARIANTS[request.status]}>
|
||||
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Created: {formatDate(request.createdAt)}</p>
|
||||
<p>Expires: {formatDate(request.expiresAt)}</p>
|
||||
<p>{formatExpiry(request.expiresAt)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => onInitiateTransfer(selectedUserId)}
|
||||
disabled={!selectedUserId}
|
||||
className="w-full bg-gradient-to-r from-amber-600 to-yellow-600 hover:from-amber-700 hover:to-yellow-700"
|
||||
size="lg"
|
||||
>
|
||||
<ArrowsLeftRight className="w-5 h-5 mr-2" />
|
||||
Initiate Power Transfer
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,15 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Textarea } from '@/components/ui'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Database } from '@/lib/database'
|
||||
import { exportPackageAsZip, importPackageFromZip, downloadZip, exportDatabaseSnapshot } from '@/lib/packages/core/package-export'
|
||||
import type { PackageManifest, PackageContent } from '@/lib/package-types'
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
import { installPackage } from '@/lib/api/packages'
|
||||
import {
|
||||
Export,
|
||||
ArrowSquareIn,
|
||||
FileArchive,
|
||||
FileArrowDown,
|
||||
FileArrowUp,
|
||||
Package,
|
||||
CloudArrowDown,
|
||||
Database as DatabaseIcon,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Image as ImageIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import { createFileSelector } from './import-export/createFileSelector'
|
||||
import { executePackageImport } from './import-export/executePackageImport'
|
||||
import { generatePackageExport } from './import-export/generatePackageExport'
|
||||
import { generateSnapshotExport } from './import-export/generateSnapshotExport'
|
||||
import { validateManifest } from './import-export/validateManifest'
|
||||
import { defaultExportOptions, defaultManifest } from './import-export/defaults'
|
||||
import { ImportDialog } from './import-export/ImportDialog'
|
||||
import { ExportDialog } from './import-export/ExportDialog'
|
||||
|
||||
interface PackageImportExportProps {
|
||||
open: boolean
|
||||
@@ -34,82 +17,27 @@ interface PackageImportExportProps {
|
||||
mode: 'export' | 'import'
|
||||
}
|
||||
|
||||
const createInitialManifest = () => ({ ...defaultManifest, tags: [...(defaultManifest.tags || [])] })
|
||||
const createInitialExportOptions = () => ({ ...defaultExportOptions })
|
||||
|
||||
export function PackageImportExport({ open, onOpenChange, mode }: PackageImportExportProps) {
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exportOptions, setExportOptions] = useState<ExportPackageOptions>({
|
||||
includeAssets: true,
|
||||
includeSchemas: true,
|
||||
includePages: true,
|
||||
includeWorkflows: true,
|
||||
includeLuaScripts: true,
|
||||
includeComponentHierarchy: true,
|
||||
includeComponentConfigs: true,
|
||||
includeCssClasses: true,
|
||||
includeDropdownConfigs: true,
|
||||
includeSeedData: true,
|
||||
})
|
||||
const [manifest, setManifest] = useState<Partial<PackageManifest>>({
|
||||
name: '',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
author: '',
|
||||
category: 'other',
|
||||
tags: [],
|
||||
})
|
||||
const [exportOptions, setExportOptions] = useState<ExportPackageOptions>(createInitialExportOptions)
|
||||
const [manifest, setManifest] = useState<Partial<PackageManifest>>(createInitialManifest)
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!manifest.name) {
|
||||
toast.error('Please provide a package name')
|
||||
const validationError = validateManifest(manifest)
|
||||
if (validationError) {
|
||||
toast.error(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
try {
|
||||
const schemas = await Database.getSchemas()
|
||||
const pages = await Database.getPages()
|
||||
const workflows = await Database.getWorkflows()
|
||||
const luaScripts = await Database.getLuaScripts()
|
||||
const componentHierarchy = await Database.getComponentHierarchy()
|
||||
const componentConfigs = await Database.getComponentConfigs()
|
||||
const cssClasses = await Database.getCssClasses()
|
||||
const dropdownConfigs = await Database.getDropdownConfigs()
|
||||
|
||||
const fullManifest: PackageManifest = {
|
||||
id: `pkg_${Date.now()}`,
|
||||
name: manifest.name!,
|
||||
version: manifest.version || '1.0.0',
|
||||
description: manifest.description || '',
|
||||
author: manifest.author || 'Anonymous',
|
||||
category: manifest.category as any || 'other',
|
||||
icon: '📦',
|
||||
screenshots: [],
|
||||
tags: manifest.tags || [],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const content: PackageContent = {
|
||||
schemas: exportOptions.includeSchemas ? schemas : [],
|
||||
pages: exportOptions.includePages ? pages : [],
|
||||
workflows: exportOptions.includeWorkflows ? workflows : [],
|
||||
luaScripts: exportOptions.includeLuaScripts ? luaScripts : [],
|
||||
componentHierarchy: exportOptions.includeComponentHierarchy ? componentHierarchy : {},
|
||||
componentConfigs: exportOptions.includeComponentConfigs ? componentConfigs : {},
|
||||
cssClasses: exportOptions.includeCssClasses ? cssClasses : undefined,
|
||||
dropdownConfigs: exportOptions.includeDropdownConfigs ? dropdownConfigs : undefined,
|
||||
}
|
||||
|
||||
const blob = await exportPackageAsZip(fullManifest, content, [], exportOptions)
|
||||
const fileName = `${manifest.name.toLowerCase().replace(/\s+/g, '-')}-${manifest.version}.zip`
|
||||
downloadZip(blob, fileName)
|
||||
|
||||
await generatePackageExport(manifest, exportOptions)
|
||||
toast.success('Package exported successfully!')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
@@ -123,29 +51,7 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
const handleExportSnapshot = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const schemas = await Database.getSchemas()
|
||||
const pages = await Database.getPages()
|
||||
const workflows = await Database.getWorkflows()
|
||||
const luaScripts = await Database.getLuaScripts()
|
||||
const componentHierarchy = await Database.getComponentHierarchy()
|
||||
const componentConfigs = await Database.getComponentConfigs()
|
||||
const cssClasses = await Database.getCssClasses()
|
||||
const dropdownConfigs = await Database.getDropdownConfigs()
|
||||
|
||||
const blob = await exportDatabaseSnapshot(
|
||||
schemas,
|
||||
pages,
|
||||
workflows,
|
||||
luaScripts,
|
||||
componentHierarchy,
|
||||
componentConfigs,
|
||||
cssClasses,
|
||||
dropdownConfigs
|
||||
)
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
downloadZip(blob, `database-snapshot-${timestamp}.zip`)
|
||||
|
||||
await generateSnapshotExport()
|
||||
toast.success('Database snapshot exported successfully!')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
@@ -159,13 +65,11 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
const handleImport = async (file: File) => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const { manifest: importedManifest, content, assets } = await importPackageFromZip(file)
|
||||
|
||||
await installPackage(importedManifest.id, { manifest: importedManifest, content })
|
||||
|
||||
const { manifest: importedManifest, content, assets } = await executePackageImport(file)
|
||||
toast.success(`Package "${importedManifest.name}" imported successfully!`)
|
||||
toast.info(`Imported: ${content.schemas.length} schemas, ${content.pages.length} pages, ${content.workflows.length} workflows, ${assets.length} assets`)
|
||||
|
||||
toast.info(
|
||||
`Imported: ${content.schemas.length} schemas, ${content.pages.length} pages, ${content.workflows.length} workflows, ${assets.length} assets`
|
||||
)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
@@ -175,22 +79,11 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
toast.error('Please select a .zip file')
|
||||
return
|
||||
}
|
||||
handleImport(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !manifest.tags?.includes(tagInput.trim())) {
|
||||
setManifest(prev => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tagInput.trim()]
|
||||
tags: [...(prev.tags || []), tagInput.trim()],
|
||||
}))
|
||||
setTagInput('')
|
||||
}
|
||||
@@ -199,396 +92,39 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setManifest(prev => ({
|
||||
...prev,
|
||||
tags: (prev.tags || []).filter(t => t !== tag)
|
||||
tags: (prev.tags || []).filter(t => t !== tag),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleFileSelect = createFileSelector(handleImport, message => toast.error(message))
|
||||
|
||||
if (mode === 'import') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<ArrowSquareIn size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Import Package</DialogTitle>
|
||||
<DialogDescription>Import a package from a ZIP file</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Select Package File</CardTitle>
|
||||
<CardDescription>Choose a .zip file containing a MetaBuilder package</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center hover:border-primary hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileArrowUp size={48} className="mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="font-medium mb-1">Click to select a package file</p>
|
||||
<p className="text-sm text-muted-foreground">Supports .zip files only</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importing && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Importing package...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">What's Included in Packages?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Data schemas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Page configurations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Workflows</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Lua scripts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Component hierarchies</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>CSS configurations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Assets (images, etc.)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Seed data</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 flex items-start gap-3">
|
||||
<Warning size={20} className="text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-900 dark:text-yellow-100 mb-1">Import Warning</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">Imported packages will be merged with existing data. Make sure to back up your database before importing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ImportDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
importing={importing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Export size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Export Package</DialogTitle>
|
||||
<DialogDescription>Create a shareable package or database snapshot</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Package size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Custom Package</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export selected data as a reusable package</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={handleExportSnapshot}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<DatabaseIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Full Snapshot</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export entire database as backup</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="package-name">Package Name *</Label>
|
||||
<Input
|
||||
id="package-name"
|
||||
placeholder="My Awesome Package"
|
||||
value={manifest.name}
|
||||
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="package-version">Version</Label>
|
||||
<Input
|
||||
id="package-version"
|
||||
placeholder="1.0.0"
|
||||
value={manifest.version}
|
||||
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-author">Author</Label>
|
||||
<Input
|
||||
id="package-author"
|
||||
placeholder="Your Name"
|
||||
value={manifest.author}
|
||||
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-description">Description</Label>
|
||||
<Textarea
|
||||
id="package-description"
|
||||
placeholder="Describe what this package does..."
|
||||
value={manifest.description}
|
||||
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-tags">Tags</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
id="package-tags"
|
||||
placeholder="Add a tag..."
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
/>
|
||||
<Button type="button" onClick={handleAddTag}>Add</Button>
|
||||
</div>
|
||||
{manifest.tags && manifest.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{manifest.tags.map(tag => (
|
||||
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Export Options</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-schemas"
|
||||
checked={exportOptions.includeSchemas}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeSchemas: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-schemas" className="font-normal cursor-pointer">
|
||||
Include data schemas
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-pages"
|
||||
checked={exportOptions.includePages}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includePages: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-pages" className="font-normal cursor-pointer">
|
||||
Include page configurations
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-workflows"
|
||||
checked={exportOptions.includeWorkflows}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeWorkflows: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-workflows" className="font-normal cursor-pointer">
|
||||
Include workflows
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-lua"
|
||||
checked={exportOptions.includeLuaScripts}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeLuaScripts: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-lua" className="font-normal cursor-pointer">
|
||||
Include Lua scripts
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-components"
|
||||
checked={exportOptions.includeComponentHierarchy}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeComponentHierarchy: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-components" className="font-normal cursor-pointer">
|
||||
Include component hierarchies
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-configs"
|
||||
checked={exportOptions.includeComponentConfigs}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeComponentConfigs: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-configs" className="font-normal cursor-pointer">
|
||||
Include component configurations
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-css"
|
||||
checked={exportOptions.includeCssClasses}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeCssClasses: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-css" className="font-normal cursor-pointer">
|
||||
Include CSS classes
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-dropdowns"
|
||||
checked={exportOptions.includeDropdownConfigs}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeDropdownConfigs: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-dropdowns" className="font-normal cursor-pointer">
|
||||
Include dropdown configurations
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-seed"
|
||||
checked={exportOptions.includeSeedData}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeSeedData: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-seed" className="font-normal cursor-pointer">
|
||||
Include seed data
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-assets"
|
||||
checked={exportOptions.includeAssets}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeAssets: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-assets" className="font-normal cursor-pointer">
|
||||
Include assets (images, videos, audio, documents)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={exporting || !manifest.name}>
|
||||
{exporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileArrowDown size={16} className="mr-2" />
|
||||
Export Package
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ExportDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
manifest={manifest}
|
||||
setManifest={setManifest}
|
||||
tagInput={tagInput}
|
||||
setTagInput={setTagInput}
|
||||
onAddTag={handleAddTag}
|
||||
onRemoveTag={handleRemoveTag}
|
||||
exportOptions={exportOptions}
|
||||
setExportOptions={setExportOptions}
|
||||
exporting={exporting}
|
||||
onExport={handleExport}
|
||||
onExportSnapshot={handleExportSnapshot}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { toast } from 'sonner'
|
||||
import { PACKAGE_CATALOG } from '@/lib/packages/core/package-catalog'
|
||||
import type { PackageManifest, PackageContent, InstalledPackage } from '@/lib/package-types'
|
||||
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
|
||||
import type { PackageManifest, InstalledPackage } from '@/lib/package-types'
|
||||
import { installPackage, listInstalledPackages, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
|
||||
import { Package, Download, Trash, Power, MagnifyingGlass, Star, Tag, User, TrendUp, Funnel, Export, ArrowSquareIn } from '@phosphor-icons/react'
|
||||
import { PackageImportExport } from './PackageImportExport'
|
||||
@@ -22,7 +22,7 @@ interface PackageManagerProps {
|
||||
export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const [packages, setPackages] = useState<PackageManifest[]>([])
|
||||
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
|
||||
const [selectedPackage, setSelectedPackage] = useState<{ manifest: PackageManifest; content: PackageContent } | null>(null)
|
||||
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
|
||||
@@ -39,10 +39,14 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const installed = await listInstalledPackages()
|
||||
setInstalledPackages(installed)
|
||||
|
||||
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => ({
|
||||
...pkg.manifest,
|
||||
installed: installed.some(ip => ip.packageId === pkg.manifest.id),
|
||||
}))
|
||||
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
|
||||
const packageData = pkg()
|
||||
|
||||
return {
|
||||
...packageData.manifest,
|
||||
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
|
||||
}
|
||||
})
|
||||
|
||||
setPackages(allPackages)
|
||||
}
|
||||
@@ -50,7 +54,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const handleInstallPackage = async (packageId: string) => {
|
||||
setInstalling(true)
|
||||
try {
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]?.()
|
||||
if (!packageEntry) {
|
||||
toast.error('Package not found')
|
||||
return
|
||||
@@ -71,7 +75,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
|
||||
const handleUninstallPackage = async (packageId: string) => {
|
||||
try {
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]?.()
|
||||
if (!packageEntry) {
|
||||
toast.error('Package not found')
|
||||
return
|
||||
@@ -227,7 +231,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
isInstalled={pkg.installed}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
@@ -253,7 +257,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
isInstalled={true}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
@@ -274,7 +278,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
isInstalled={false}
|
||||
installedPackage={undefined}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type React from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Textarea } from '@/components/ui'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { Export, Package, Database as DatabaseIcon, FileArrowDown } from '@phosphor-icons/react'
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
|
||||
const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
|
||||
{ key: 'includeSchemas', label: 'Include data schemas' },
|
||||
{ key: 'includePages', label: 'Include page configurations' },
|
||||
{ key: 'includeWorkflows', label: 'Include workflows' },
|
||||
{ key: 'includeLuaScripts', label: 'Include Lua scripts' },
|
||||
{ key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
|
||||
{ key: 'includeComponentConfigs', label: 'Include component configurations' },
|
||||
{ key: 'includeCssClasses', label: 'Include CSS classes' },
|
||||
{ key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
|
||||
{ key: 'includeSeedData', label: 'Include seed data' },
|
||||
{ key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
|
||||
]
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
manifest: Partial<PackageManifest>
|
||||
setManifest: React.Dispatch<React.SetStateAction<Partial<PackageManifest>>>
|
||||
tagInput: string
|
||||
setTagInput: (value: string) => void
|
||||
onAddTag: () => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
exportOptions: ExportPackageOptions
|
||||
setExportOptions: React.Dispatch<React.SetStateAction<ExportPackageOptions>>
|
||||
exporting: boolean
|
||||
onExport: () => void
|
||||
onExportSnapshot: () => void
|
||||
}
|
||||
|
||||
export const ExportDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
manifest,
|
||||
setManifest,
|
||||
tagInput,
|
||||
setTagInput,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
exportOptions,
|
||||
setExportOptions,
|
||||
exporting,
|
||||
onExport,
|
||||
onExportSnapshot,
|
||||
}: ExportDialogProps) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Export size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Export Package</DialogTitle>
|
||||
<DialogDescription>Create a shareable package or database snapshot</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Package size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Custom Package</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export selected data as a reusable package</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary transition-colors" onClick={onExportSnapshot}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<DatabaseIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Full Snapshot</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export entire database as backup</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="package-name">Package Name *</Label>
|
||||
<Input
|
||||
id="package-name"
|
||||
placeholder="My Awesome Package"
|
||||
value={manifest.name}
|
||||
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="package-version">Version</Label>
|
||||
<Input
|
||||
id="package-version"
|
||||
placeholder="1.0.0"
|
||||
value={manifest.version}
|
||||
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-author">Author</Label>
|
||||
<Input
|
||||
id="package-author"
|
||||
placeholder="Your Name"
|
||||
value={manifest.author}
|
||||
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-description">Description</Label>
|
||||
<Textarea
|
||||
id="package-description"
|
||||
placeholder="Describe what this package does..."
|
||||
value={manifest.description}
|
||||
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-tags">Tags</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
id="package-tags"
|
||||
placeholder="Add a tag..."
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
|
||||
/>
|
||||
<Button type="button" onClick={onAddTag}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{manifest.tags && manifest.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{manifest.tags.map(tag => (
|
||||
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveTag(tag)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Export Options</Label>
|
||||
<div className="space-y-3">
|
||||
{exportOptionLabels.map(({ key, label }) => (
|
||||
<div className="flex items-center gap-2" key={key}>
|
||||
<Checkbox
|
||||
id={`export-${key}`}
|
||||
checked={exportOptions[key] as boolean}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`export-${key}`} className="font-normal cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onExport} disabled={exporting || !manifest.name}>
|
||||
{exporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileArrowDown size={16} className="mr-2" />
|
||||
Export Package
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import type React from 'react'
|
||||
import { ArrowSquareIn, FileArrowUp } from '@phosphor-icons/react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { ImportStatus } from './StatusUI'
|
||||
|
||||
interface ImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
importing: boolean
|
||||
}
|
||||
|
||||
export const ImportDialog = ({ open, onOpenChange, fileInputRef, onFileSelect, importing }: ImportDialogProps) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<ArrowSquareIn size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Import Package</DialogTitle>
|
||||
<DialogDescription>Import a package from a ZIP file</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ImportStatus
|
||||
importing={importing}
|
||||
selectionSlot={
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center hover:border-primary hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileArrowUp size={48} className="mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="font-medium mb-1">Click to select a package file</p>
|
||||
<p className="text-sm text-muted-foreground">Supports .zip files only</p>
|
||||
<input ref={fileInputRef} type="file" accept=".zip" onChange={onFileSelect} className="hidden" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { CheckCircle, Warning } from '@phosphor-icons/react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
|
||||
interface ImportStatusProps {
|
||||
importing: boolean
|
||||
selectionSlot: ReactNode
|
||||
}
|
||||
|
||||
export const ImportStatus = ({ importing, selectionSlot }: ImportStatusProps) => (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Select Package File</CardTitle>
|
||||
<CardDescription>Choose a .zip file containing a MetaBuilder package</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectionSlot}
|
||||
{importing && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Importing package...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">What's Included in Packages?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{['Data schemas', 'Page configurations', 'Workflows', 'Lua scripts', 'Component hierarchies', 'CSS configurations', 'Assets (images, etc.)', 'Seed data'].map(item => (
|
||||
<div key={item} className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 flex items-start gap-3">
|
||||
<Warning size={20} className="text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-900 dark:text-yellow-100 mb-1">Import Warning</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
Imported packages will be merged with existing data. Make sure to back up your database before importing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import type React from 'react'
|
||||
|
||||
export const createFileSelector = (
|
||||
onValidFile: (file: File) => void,
|
||||
onInvalid: (message: string) => void
|
||||
) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
onInvalid('Please select a .zip file')
|
||||
return
|
||||
}
|
||||
|
||||
onValidFile(file)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
|
||||
export const defaultExportOptions: ExportPackageOptions = {
|
||||
includeAssets: true,
|
||||
includeSchemas: true,
|
||||
includePages: true,
|
||||
includeWorkflows: true,
|
||||
includeLuaScripts: true,
|
||||
includeComponentHierarchy: true,
|
||||
includeComponentConfigs: true,
|
||||
includeCssClasses: true,
|
||||
includeDropdownConfigs: true,
|
||||
includeSeedData: true,
|
||||
}
|
||||
|
||||
export const defaultManifest: Partial<PackageManifest> = {
|
||||
name: '',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
author: '',
|
||||
category: 'other',
|
||||
tags: [],
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { installPackage } from '@/lib/api/packages'
|
||||
import { importPackageFromZip } from '@/lib/packages/core/package-export'
|
||||
|
||||
export const executePackageImport = async (file: File) => {
|
||||
const { manifest, content, assets } = await importPackageFromZip(file)
|
||||
await installPackage(manifest.id, { manifest, content })
|
||||
|
||||
return { manifest, content, assets }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Database } from '@/lib/database'
|
||||
import { downloadZip, exportPackageAsZip } from '@/lib/packages/core/package-export'
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
import type { PackageContent, PackageManifest } from '@/lib/package-types'
|
||||
|
||||
const buildManifest = (manifest: Partial<PackageManifest>): PackageManifest => ({
|
||||
id: `pkg_${Date.now()}`,
|
||||
name: manifest.name!,
|
||||
version: manifest.version || '1.0.0',
|
||||
description: manifest.description || '',
|
||||
author: manifest.author || 'Anonymous',
|
||||
category: (manifest.category as any) || 'other',
|
||||
icon: '📦',
|
||||
screenshots: [],
|
||||
tags: manifest.tags || [],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
installed: false,
|
||||
})
|
||||
|
||||
const buildContent = async (exportOptions: ExportPackageOptions): Promise<PackageContent> => {
|
||||
const [schemas, pages, workflows, luaScripts, componentHierarchy, componentConfigs, cssClasses, dropdownConfigs] =
|
||||
await Promise.all([
|
||||
Database.getSchemas(),
|
||||
Database.getPages(),
|
||||
Database.getWorkflows(),
|
||||
Database.getLuaScripts(),
|
||||
Database.getComponentHierarchy(),
|
||||
Database.getComponentConfigs(),
|
||||
Database.getCssClasses(),
|
||||
Database.getDropdownConfigs(),
|
||||
])
|
||||
|
||||
return {
|
||||
schemas: exportOptions.includeSchemas ? schemas : [],
|
||||
pages: exportOptions.includePages ? pages : [],
|
||||
workflows: exportOptions.includeWorkflows ? workflows : [],
|
||||
luaScripts: exportOptions.includeLuaScripts ? luaScripts : [],
|
||||
componentHierarchy: exportOptions.includeComponentHierarchy ? componentHierarchy : {},
|
||||
componentConfigs: exportOptions.includeComponentConfigs ? componentConfigs : {},
|
||||
cssClasses: exportOptions.includeCssClasses ? cssClasses : undefined,
|
||||
dropdownConfigs: exportOptions.includeDropdownConfigs ? dropdownConfigs : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePackageExport = async (
|
||||
manifest: Partial<PackageManifest>,
|
||||
exportOptions: ExportPackageOptions
|
||||
) => {
|
||||
const fullManifest = buildManifest(manifest)
|
||||
const content = await buildContent(exportOptions)
|
||||
const blob = await exportPackageAsZip(fullManifest, content, [], exportOptions)
|
||||
const version = manifest.version || '1.0.0'
|
||||
const sanitizedName = manifest.name?.toLowerCase().replace(/\s+/g, '-') || 'package'
|
||||
const fileName = `${sanitizedName}-${version}.zip`
|
||||
|
||||
downloadZip(blob, fileName)
|
||||
|
||||
return { fileName }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Database } from '@/lib/database'
|
||||
import { downloadZip, exportDatabaseSnapshot } from '@/lib/packages/core/package-export'
|
||||
|
||||
export const generateSnapshotExport = async () => {
|
||||
const [schemas, pages, workflows, luaScripts, componentHierarchy, componentConfigs, cssClasses, dropdownConfigs] =
|
||||
await Promise.all([
|
||||
Database.getSchemas(),
|
||||
Database.getPages(),
|
||||
Database.getWorkflows(),
|
||||
Database.getLuaScripts(),
|
||||
Database.getComponentHierarchy(),
|
||||
Database.getComponentConfigs(),
|
||||
Database.getCssClasses(),
|
||||
Database.getDropdownConfigs(),
|
||||
])
|
||||
|
||||
const blob = await exportDatabaseSnapshot(
|
||||
schemas,
|
||||
pages,
|
||||
workflows,
|
||||
luaScripts,
|
||||
componentHierarchy,
|
||||
componentConfigs,
|
||||
cssClasses,
|
||||
dropdownConfigs
|
||||
)
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
downloadZip(blob, `database-snapshot-${timestamp}.zip`)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
|
||||
export const validateManifest = (manifest: Partial<PackageManifest>) => {
|
||||
if (!manifest.name?.trim()) {
|
||||
return 'Please provide a package name'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { formatWorkflowLogAnalysis, summarizeWorkflowLogs } from '@/lib/github/analyze-workflow-logs'
|
||||
|
||||
import { Job, RepoInfo, WorkflowRun } from '../types'
|
||||
|
||||
interface UseWorkflowLogAnalysisOptions {
|
||||
repoInfo: RepoInfo | null
|
||||
onAnalysisStart?: () => void
|
||||
onAnalysisComplete?: (report: string | null) => void
|
||||
}
|
||||
|
||||
export function useWorkflowLogAnalysis({
|
||||
repoInfo,
|
||||
onAnalysisStart,
|
||||
onAnalysisComplete,
|
||||
}: UseWorkflowLogAnalysisOptions) {
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(null)
|
||||
const [runJobs, setRunJobs] = useState<Job[]>([])
|
||||
const [runLogs, setRunLogs] = useState<string | null>(null)
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
||||
|
||||
const downloadRunLogs = useCallback(
|
||||
async (runId: number, runName: string) => {
|
||||
setIsLoadingLogs(true)
|
||||
setSelectedRunId(runId)
|
||||
setRunLogs(null)
|
||||
setRunJobs([])
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
runName,
|
||||
includeLogs: 'true',
|
||||
jobLimit: '20',
|
||||
})
|
||||
if (repoInfo) {
|
||||
query.set('owner', repoInfo.owner)
|
||||
query.set('repo', repoInfo.repo)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/github/actions/runs/${runId}/logs?${query.toString()}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
let payload: {
|
||||
jobs?: Job[]
|
||||
logsText?: string | null
|
||||
truncated?: boolean
|
||||
requiresAuth?: boolean
|
||||
error?: string
|
||||
} | null = null
|
||||
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload?.requiresAuth) {
|
||||
toast.error('GitHub API requires authentication for logs')
|
||||
}
|
||||
const message = payload?.error || `Failed to download logs (${response.status})`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const logsText = payload?.logsText ?? null
|
||||
setRunJobs(payload?.jobs ?? [])
|
||||
setRunLogs(logsText)
|
||||
|
||||
if (logsText) {
|
||||
const blob = new Blob([logsText], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (payload?.truncated) {
|
||||
toast.info('Downloaded logs are truncated. Increase the job limit for more.')
|
||||
}
|
||||
|
||||
toast.success('Workflow logs downloaded successfully')
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to download logs'
|
||||
toast.error(errorMessage)
|
||||
setRunLogs(`Error fetching logs: ${errorMessage}`)
|
||||
} finally {
|
||||
setIsLoadingLogs(false)
|
||||
}
|
||||
},
|
||||
[repoInfo],
|
||||
)
|
||||
|
||||
const analyzeRunLogs = useCallback(
|
||||
async (runs: WorkflowRun[] | null) => {
|
||||
if (!runLogs || !selectedRunId) {
|
||||
toast.error('No logs to analyze')
|
||||
return
|
||||
}
|
||||
|
||||
onAnalysisStart?.()
|
||||
try {
|
||||
const selectedRun = runs?.find(r => r.id === selectedRunId)
|
||||
const summary = summarizeWorkflowLogs(runLogs)
|
||||
const report = formatWorkflowLogAnalysis(summary, {
|
||||
runName: selectedRun?.name,
|
||||
runId: selectedRunId,
|
||||
})
|
||||
onAnalysisComplete?.(report)
|
||||
toast.success('Log analysis complete')
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
|
||||
toast.error(errorMessage)
|
||||
onAnalysisComplete?.(null)
|
||||
}
|
||||
},
|
||||
[onAnalysisComplete, onAnalysisStart, runLogs, selectedRunId],
|
||||
)
|
||||
|
||||
return {
|
||||
analyzeRunLogs,
|
||||
downloadRunLogs,
|
||||
isLoadingLogs,
|
||||
runJobs,
|
||||
runLogs,
|
||||
selectedRunId,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user