Merge development into main (#42)

* feat: improve button layout and UI organization (#35)

- Reorganize control buttons into a structured container with proper spacing
- Add responsive design for mobile and desktop layouts
- Improve SettingsButton and ResyncButton component structure
- Enhance visual hierarchy with better typography and spacing
- Add background container with shadow and border for better grouping
- Make layout responsive with proper flexbox arrangements

* Add category sidebar and filtering to scripts grid (#36)

* Add category sidebar and filtering to scripts grid

Introduces a CategorySidebar component with icon mapping and category selection. Updates metadata.json to include icons for each category. Enhances ScriptsGrid to support category-based filtering and integrates the sidebar, improving script navigation and discoverability. Also refines ScriptDetailModal layout for better modal presentation.

* Add category metadata to scripts and improve filtering

Introduces category metadata loading and exposes it via new API endpoints. Script cards are now enhanced with category information, allowing for accurate category-based filtering and counting in the ScriptsGrid component. Removes hardcoded category logic and replaces it with dynamic data from metadata.json.

* Add reusable Badge component and refactor badge usage (#37)

Introduces a new Badge component with variants for type, updateable, privileged, status, execution mode, and note. Refactors ScriptCard, ScriptDetailModal, and InstalledScriptsTab to use the new Badge components, improving consistency and maintainability. Also updates DarkModeProvider and layout.tsx for better dark mode handling and fallback.

* Add advanced filtering and sorting to ScriptsGrid (#38)

Introduces a new FilterBar component for ScriptsGrid, enabling filtering by search query, updatable status, script types, and sorting by name or creation date. Updates scripts API to include creation date in card data, improves deduplication and category counting logic, and adds error handling for missing script directories.

* refactore installed scipts tab (#41)

* feat: Add inline editing and manual script entry functionality

- Add inline editing for script names and container IDs in installed scripts table
- Add manual script entry form for pre-installed containers
- Update database and API to support script_name editing
- Improve dark mode hover effects for table rows
- Add form validation and error handling
- Support both local and SSH execution modes for manual entries

* feat: implement installed scripts functionality and clean up test files

- Add installed scripts tab with filtering and execution capabilities
- Update scripts grid with better type safety and error handling
- Remove outdated test files and update test configuration
- Fix TypeScript and ESLint issues in components
- Update .gitattributes for proper line ending handling

* fix: resolve TypeScript error with categoryNames type mismatch

- Fixed categoryNames type from (string | undefined)[] to string[] in scripts router
- Added proper type filtering and assertion in getScriptCardsWithCategories
- Added missing ScriptCard import in scripts router
- Ensures type safety for categoryNames property throughout the application

---------

Co-authored-by: CanbiZ <47820557+MickLesk@users.noreply.github.com>
This commit is contained in:
Michel Roegl-Brunner
2025-10-06 16:24:19 +02:00
committed by GitHub
parent a05185db1b
commit e09c1bbf5d
98 changed files with 3273 additions and 2278 deletions

View File

@@ -1,368 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createCallerFactory } from '~/server/api/trpc'
import { scriptsRouter } from '../scripts'
// Mock dependencies
vi.mock('~/server/lib/scripts', () => ({
scriptManager: {
getScripts: vi.fn(),
getCtScripts: vi.fn(),
validateScriptPath: vi.fn(),
getScriptsDirectoryInfo: vi.fn(),
},
}))
vi.mock('~/server/lib/git', () => ({
gitManager: {
getStatus: vi.fn(),
pullUpdates: vi.fn(),
},
}))
vi.mock('~/server/services/githubJsonService', () => ({
githubJsonService: {
syncJsonFiles: vi.fn(),
getAllScripts: vi.fn(),
getScriptBySlug: vi.fn(),
},
}))
vi.mock('~/server/services/localScripts', () => ({
localScriptsService: {
getScriptCards: vi.fn(),
getAllScripts: vi.fn(),
getScriptBySlug: vi.fn(),
saveScriptsFromGitHub: vi.fn(),
},
}))
vi.mock('~/server/services/scriptDownloader', () => ({
scriptDownloaderService: {
loadScript: vi.fn(),
checkScriptExists: vi.fn(),
compareScriptContent: vi.fn(),
getScriptDiff: vi.fn(),
},
}))
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
}))
vi.mock('path', () => ({
join: vi.fn((...args) => {
// Simulate path.join behavior for security check
const result = args.join('/')
// If the path contains '..', it should be considered invalid
if (result.includes('../')) {
return '/invalid/path'
}
return result
}),
}))
vi.mock('~/env', () => ({
env: {
SCRIPTS_DIRECTORY: '/test/scripts',
},
}))
describe('scriptsRouter', () => {
let caller: ReturnType<typeof createCallerFactory<typeof scriptsRouter>>
beforeEach(() => {
vi.clearAllMocks()
caller = createCallerFactory(scriptsRouter)({})
})
describe('getScripts', () => {
it('should return scripts and directory info', async () => {
const mockScripts = [
{ name: 'test.sh', path: '/test/scripts/test.sh', extension: '.sh' },
]
const mockDirectoryInfo = {
path: '/test/scripts',
allowedExtensions: ['.sh'],
allowedPaths: ['/'],
maxExecutionTime: 30000,
}
const { scriptManager } = await import('~/server/lib/scripts')
vi.mocked(scriptManager.getScripts).mockResolvedValue(mockScripts)
vi.mocked(scriptManager.getScriptsDirectoryInfo).mockReturnValue(mockDirectoryInfo)
const result = await caller.getScripts()
expect(result).toEqual({
scripts: mockScripts,
directoryInfo: mockDirectoryInfo,
})
})
})
describe('getCtScripts', () => {
it('should return CT scripts and directory info', async () => {
const mockScripts = [
{ name: 'ct-test.sh', path: '/test/scripts/ct/ct-test.sh', slug: 'ct-test' },
]
const mockDirectoryInfo = {
path: '/test/scripts',
allowedExtensions: ['.sh'],
allowedPaths: ['/'],
maxExecutionTime: 30000,
}
const { scriptManager } = await import('~/server/lib/scripts')
vi.mocked(scriptManager.getCtScripts).mockResolvedValue(mockScripts)
vi.mocked(scriptManager.getScriptsDirectoryInfo).mockReturnValue(mockDirectoryInfo)
const result = await caller.getCtScripts()
expect(result).toEqual({
scripts: mockScripts,
directoryInfo: mockDirectoryInfo,
})
})
})
describe('getScriptContent', () => {
it('should return script content for valid path', async () => {
const mockContent = '#!/bin/bash\necho "Hello World"'
const { readFile } = await import('fs/promises')
vi.mocked(readFile).mockResolvedValue(mockContent)
const result = await caller.getScriptContent({ path: 'test.sh' })
expect(result).toEqual({
success: true,
content: mockContent,
})
})
it('should return error for invalid path', async () => {
const result = await caller.getScriptContent({ path: '../../../etc/passwd' })
expect(result).toEqual({
success: false,
error: 'Failed to read script content',
})
})
})
describe('validateScript', () => {
it('should return validation result', async () => {
const mockValidation = { valid: true }
const { scriptManager } = await import('~/server/lib/scripts')
vi.mocked(scriptManager.validateScriptPath).mockReturnValue(mockValidation)
const result = await caller.validateScript({ scriptPath: '/test/scripts/test.sh' })
expect(result).toEqual(mockValidation)
})
})
describe('getDirectoryInfo', () => {
it('should return directory information', async () => {
const mockDirectoryInfo = {
path: '/test/scripts',
allowedExtensions: ['.sh'],
allowedPaths: ['/'],
maxExecutionTime: 30000,
}
const { scriptManager } = await import('~/server/lib/scripts')
vi.mocked(scriptManager.getScriptsDirectoryInfo).mockReturnValue(mockDirectoryInfo)
const result = await caller.getDirectoryInfo()
expect(result).toEqual(mockDirectoryInfo)
})
})
describe('getScriptCards', () => {
it('should return script cards on success', async () => {
const mockCards = [
{ name: 'Test Script', slug: 'test-script' },
]
const { localScriptsService } = await import('~/server/services/localScripts')
vi.mocked(localScriptsService.getScriptCards).mockResolvedValue(mockCards)
const result = await caller.getScriptCards()
expect(result).toEqual({
success: true,
cards: mockCards,
})
})
it('should return error on failure', async () => {
const { localScriptsService } = await import('~/server/services/localScripts')
vi.mocked(localScriptsService.getScriptCards).mockRejectedValue(new Error('Test error'))
const result = await caller.getScriptCards()
expect(result).toEqual({
success: false,
error: 'Test error',
cards: [],
})
})
})
describe('getScriptBySlug', () => {
it('should return script on success', async () => {
const mockScript = { name: 'Test Script', slug: 'test-script' }
const { githubJsonService } = await import('~/server/services/githubJsonService')
vi.mocked(githubJsonService.getScriptBySlug).mockResolvedValue(mockScript)
const result = await caller.getScriptBySlug({ slug: 'test-script' })
expect(result).toEqual({
success: true,
script: mockScript,
})
})
it('should return error when script not found', async () => {
const { githubJsonService } = await import('~/server/services/githubJsonService')
vi.mocked(githubJsonService.getScriptBySlug).mockResolvedValue(null)
const result = await caller.getScriptBySlug({ slug: 'nonexistent' })
expect(result).toEqual({
success: false,
error: 'Script not found',
script: null,
})
})
})
describe('resyncScripts', () => {
it('should resync scripts successfully', async () => {
const { githubJsonService } = await import('~/server/services/githubJsonService')
vi.mocked(githubJsonService.syncJsonFiles).mockResolvedValue({
success: true,
message: 'Successfully synced 2 scripts from GitHub using 1 API call + raw downloads',
count: 2
})
const result = await caller.resyncScripts()
expect(result).toEqual({
success: true,
message: 'Successfully synced 2 scripts from GitHub using 1 API call + raw downloads',
count: 2,
})
})
it('should return error on failure', async () => {
const { githubJsonService } = await import('~/server/services/githubJsonService')
vi.mocked(githubJsonService.syncJsonFiles).mockResolvedValue({
success: false,
message: 'GitHub error',
count: 0
})
const result = await caller.resyncScripts()
expect(result).toEqual({
success: false,
message: 'GitHub error',
count: 0,
})
})
})
describe('loadScript', () => {
it('should load script successfully', async () => {
const mockScript = { name: 'Test Script', slug: 'test-script' }
const mockResult = { success: true, files: ['test.sh'] }
const { localScriptsService } = await import('~/server/services/localScripts')
const { scriptDownloaderService } = await import('~/server/services/scriptDownloader')
vi.mocked(localScriptsService.getScriptBySlug).mockResolvedValue(mockScript)
vi.mocked(scriptDownloaderService.loadScript).mockResolvedValue(mockResult)
const result = await caller.loadScript({ slug: 'test-script' })
expect(result).toEqual(mockResult)
})
it('should return error when script not found', async () => {
const { localScriptsService } = await import('~/server/services/localScripts')
vi.mocked(localScriptsService.getScriptBySlug).mockResolvedValue(null)
const result = await caller.loadScript({ slug: 'nonexistent' })
expect(result).toEqual({
success: false,
error: 'Script not found',
files: [],
})
})
})
describe('checkScriptFiles', () => {
it('should check script files successfully', async () => {
const mockScript = { name: 'Test Script', slug: 'test-script' }
const mockResult = { ctExists: true, installExists: false, files: ['test.sh'] }
const { localScriptsService } = await import('~/server/services/localScripts')
const { scriptDownloaderService } = await import('~/server/services/scriptDownloader')
vi.mocked(localScriptsService.getScriptBySlug).mockResolvedValue(mockScript)
vi.mocked(scriptDownloaderService.checkScriptExists).mockResolvedValue(mockResult)
const result = await caller.checkScriptFiles({ slug: 'test-script' })
expect(result).toEqual({
success: true,
...mockResult,
})
})
})
describe('compareScriptContent', () => {
it('should compare script content successfully', async () => {
const mockScript = { name: 'Test Script', slug: 'test-script' }
const mockResult = { hasDifferences: true, differences: ['line 1'] }
const { localScriptsService } = await import('~/server/services/localScripts')
const { scriptDownloaderService } = await import('~/server/services/scriptDownloader')
vi.mocked(localScriptsService.getScriptBySlug).mockResolvedValue(mockScript)
vi.mocked(scriptDownloaderService.compareScriptContent).mockResolvedValue(mockResult)
const result = await caller.compareScriptContent({ slug: 'test-script' })
expect(result).toEqual({
success: true,
...mockResult,
})
})
})
describe('getScriptDiff', () => {
it('should get script diff successfully', async () => {
const mockScript = { name: 'Test Script', slug: 'test-script' }
const mockResult = { diff: 'diff content' }
const { localScriptsService } = await import('~/server/services/localScripts')
const { scriptDownloaderService } = await import('~/server/services/scriptDownloader')
vi.mocked(localScriptsService.getScriptBySlug).mockResolvedValue(mockScript)
vi.mocked(scriptDownloaderService.getScriptDiff).mockResolvedValue(mockResult)
const result = await caller.getScriptDiff({ slug: 'test-script', filePath: 'test.sh' })
expect(result).toEqual({
success: true,
...mockResult,
})
})
})
})

View File

@@ -105,6 +105,7 @@ export const installedScriptsRouter = createTRPCRouter({
updateInstalledScript: publicProcedure
.input(z.object({
id: z.number(),
script_name: z.string().optional(),
container_id: z.string().optional(),
status: z.enum(['in_progress', 'success', 'failed']).optional(),
output_log: z.string().optional()

View File

@@ -4,6 +4,7 @@ import { scriptManager } from "~/server/lib/scripts";
import { githubJsonService } from "~/server/services/githubJsonService";
import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader";
import type { ScriptCard } from "~/types/script";
export const scriptsRouter = createTRPCRouter({
// Get all available scripts
@@ -121,6 +122,68 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Get metadata (categories and other metadata)
getMetadata: publicProcedure
.query(async () => {
try {
const metadata = await localScriptsService.getMetadata();
return { success: true, metadata };
} catch (error) {
console.error('Error in getMetadata:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch metadata',
metadata: null
};
}
}),
// Get script cards with category information
getScriptCardsWithCategories: publicProcedure
.query(async () => {
try {
const [cards, metadata] = await Promise.all([
localScriptsService.getScriptCards(),
localScriptsService.getMetadata()
]);
// Get all scripts to access their categories
const scripts = await localScriptsService.getAllScripts();
// Create category ID to name mapping
const categoryMap: Record<number, string> = {};
if (metadata?.categories) {
metadata.categories.forEach((cat: any) => {
categoryMap[cat.id] = cat.name;
});
}
// Enhance cards with category information and additional script data
const cardsWithCategories = cards.map(card => {
const script = scripts.find(s => s.slug === card.slug);
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
return {
...card,
categories: script?.categories ?? [],
categoryNames: categoryNames,
// Add date_created from script
date_created: script?.date_created,
} as ScriptCard;
});
return { success: true, cards: cardsWithCategories, metadata };
} catch (error) {
console.error('Error in getScriptCardsWithCategories:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch script cards with categories',
cards: [],
metadata: null
};
}
}),
// Resync scripts from GitHub (1 API call + raw downloads)
resyncScripts: publicProcedure
.mutation(async () => {