From 356415eaac37dbe3a9737e4de7f6e030b5c594d5 Mon Sep 17 00:00:00 2001 From: carlosrfjunior Date: Fri, 12 Dec 2025 22:09:41 -0300 Subject: [PATCH 1/6] chore: Add search button in Migration List --- ffm/Dockerfile.dev | 2 +- ffm/package-lock.json | 31 +++++++++- ffm/package.json | 6 +- ffm/src/components/Dashboard.tsx | 91 +++++++++++++++++++++++----- ffm/src/components/Layout.tsx | 28 +++++++++ ffm/src/components/MigrationList.tsx | 77 ++++++++++++++++++++--- ffm/src/services/api.ts | 2 +- ffm/start.sh | 29 ++++++--- 8 files changed, 229 insertions(+), 37 deletions(-) diff --git a/ffm/Dockerfile.dev b/ffm/Dockerfile.dev index c9a3080..5d78f6c 100644 --- a/ffm/Dockerfile.dev +++ b/ffm/Dockerfile.dev @@ -7,7 +7,7 @@ WORKDIR /app # Install dependencies (will be overridden by volume mount, but useful for initial setup) COPY package*.json .npmrc* ./ -RUN npm ci +RUN npm install # Copy source code (will be mounted as volume in docker-compose) # This copy is for initial setup, actual development uses volume mount diff --git a/ffm/package-lock.json b/ffm/package-lock.json index 22a400e..8fced82 100644 --- a/ffm/package-lock.json +++ b/ffm/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "axios": "^1.6.2", "date-fns": "^2.30.0", - "react": "^19.2.0", + "react": "^19.2.3", "react-dom": "^19.2.3", + "react-is": "^18.3.1", "react-router-dom": "^7.10.1", "recharts": "^3.5.1" }, "devDependencies": { + "@types/node": "^22.10.2", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.48.1", @@ -1497,6 +1499,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", + "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/react": { "version": "19.2.6", "dev": true, @@ -3968,7 +3980,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.0", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3986,6 +4000,12 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -4462,6 +4482,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "dev": true, diff --git a/ffm/package.json b/ffm/package.json index 680b31d..7d4e7d6 100644 --- a/ffm/package.json +++ b/ffm/package.json @@ -13,12 +13,14 @@ "dependencies": { "axios": "^1.6.2", "date-fns": "^2.30.0", - "react": "^19.2.0", - "react-router-dom": "^7.10.1", + "react": "^19.2.3", "react-dom": "^19.2.3", + "react-is": "^18.3.1", + "react-router-dom": "^7.10.1", "recharts": "^3.5.1" }, "devDependencies": { + "@types/node": "^22.10.2", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.48.1", diff --git a/ffm/src/components/Dashboard.tsx b/ffm/src/components/Dashboard.tsx index ce81c63..f644a42 100644 --- a/ffm/src/components/Dashboard.tsx +++ b/ffm/src/components/Dashboard.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; import { apiClient } from "../services/api"; +import { toastService } from "../services/toast"; import type { MigrationListItem } from "../types/api"; import { format } from "date-fns"; import { @@ -22,6 +23,7 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [healthStatus, setHealthStatus] = useState("unknown"); + const [reindexing, setReindexing] = useState(false); useEffect(() => { loadData(); @@ -51,6 +53,35 @@ export default function Dashboard() { } }; + const handleReindex = async () => { + if (reindexing) return; + + setReindexing(true); + try { + const result = await apiClient.reindexMigrations(); + const addedCount = result.added.length; + const removedCount = result.removed.length; + + let message = `Reindexing completed. Total migrations: ${result.total}`; + if (addedCount > 0 || removedCount > 0) { + message += ` (Added: ${addedCount}, Removed: ${removedCount})`; + } else { + message += " (No changes)"; + } + + toastService.success(message); + + // Reload migrations list to reflect changes + await loadData(); + } catch (err) { + const errorMsg = + err instanceof Error ? err.message : "Failed to reindex migrations"; + toastService.error(errorMsg); + } finally { + setReindexing(false); + } + }; + if (loading && migrations.length === 0) { return (
@@ -202,14 +233,23 @@ export default function Dashboard() {

Migration Dashboard

-
- Status: {healthStatus === "healthy" ? "✓ Healthy" : "✗ Unhealthy"} +
+ +
+ Status: {healthStatus === "healthy" ? "✓ Healthy" : "✗ Unhealthy"} +
@@ -221,8 +261,16 @@ export default function Dashboard() {
Health Score
-
- +
+ Status Distribution - + { // Only show label if slice is large enough (>= 5%) - if (percent >= 0.05) { + if (percent !== undefined && percent >= 0.05) { return `${name}\n${value} (${(percent * 100).toFixed(0)}%)`; } return ""; @@ -373,7 +426,12 @@ export default function Dashboard() {

Migrations by Backend

- + @@ -388,7 +446,12 @@ export default function Dashboard() {

Migrations by Connection

- + diff --git a/ffm/src/components/Layout.tsx b/ffm/src/components/Layout.tsx index 1dc2f30..802fb08 100644 --- a/ffm/src/components/Layout.tsx +++ b/ffm/src/components/Layout.tsx @@ -193,6 +193,34 @@ export default function Layout({ onLogout }: LayoutProps) { + {authEnabled && (
{ loadMigrations(); @@ -201,16 +202,36 @@ export default function MigrationList() { setCurrentPage(1); }; + // Filter migrations based on search query + const filteredMigrations = useMemo(() => { + if (!searchQuery.trim()) { + return migrations; + } + const query = searchQuery.toLowerCase(); + return migrations.filter((migration) => { + return ( + migration.migration_id.toLowerCase().includes(query) || + migration.version.toLowerCase().includes(query) || + migration.name.toLowerCase().includes(query) || + migration.table.toLowerCase().includes(query) || + migration.backend.toLowerCase().includes(query) || + (migration.connection && + migration.connection.toLowerCase().includes(query)) || + (migration.schema && migration.schema.toLowerCase().includes(query)) + ); + }); + }, [migrations, searchQuery]); + // Calculate pagination - const totalPages = Math.ceil(migrations.length / itemsPerPage); + const totalPages = Math.ceil(filteredMigrations.length / itemsPerPage); const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; - const paginatedMigrations = migrations.slice(startIndex, endIndex); + const paginatedMigrations = filteredMigrations.slice(startIndex, endIndex); - // Reset to page 1 when filters change + // Reset to page 1 when filters or search query change useEffect(() => { setCurrentPage(1); - }, [filters]); + }, [filters, searchQuery]); // Clear selection when filters change useEffect(() => { @@ -233,7 +254,7 @@ export default function MigrationList() { // Handle select all on current page const handleSelectAll = () => { - if (selectedMigrations.size === paginatedMigrations.length) { + if (allPageSelected) { // Deselect all on current page setSelectedMigrations((prev) => { const newSet = new Set(prev); @@ -718,9 +739,43 @@ export default function MigrationList() {
)} -
+

Migrations

-
+
+
+ ) => + setSearchQuery(e.target.value) + } + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + setCurrentPage(1); + } else if (e.key === "Escape") { + setSearchQuery(""); + } + }} + className="flex-1 sm:w-64 px-3 py-2 border border-gray-300 rounded text-sm bg-white text-gray-800 focus:outline-none focus:border-bfm-blue focus:ring-2 focus:ring-bfm-blue/20" + /> + + {searchQuery && ( + + )} +
+ +
+ Page {historyPage} of{" "} + {Math.ceil(history.length / historyPerPage)} +
+ + +
+
+ )}
)} diff --git a/ffm/src/components/MigrationList.tsx b/ffm/src/components/MigrationList.tsx index b53f664..9458b98 100644 --- a/ffm/src/components/MigrationList.tsx +++ b/ffm/src/components/MigrationList.tsx @@ -34,7 +34,6 @@ export default function MigrationList() { const [forceRollback, setForceRollback] = useState(false); const [rollingBack, setRollingBack] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [expandedGroups, setExpandedGroups] = useState>(new Set()); useEffect(() => { loadMigrations(); @@ -203,13 +202,20 @@ export default function MigrationList() { setCurrentPage(1); }; + // Helper function to check if a string is a version number (timestamp format: YYYYMMDDHHMMSS) + const isVersionNumber = (str: string): boolean => { + // Check if it's a 14-digit number (timestamp format) + return /^\d{14}$/.test(str); + }; + // Helper function to extract base ID from schema-specific ID const getBaseMigrationID = (migrationID: string): string => { const parts = migrationID.split("_"); - // Schema-specific format: {schema}_{version}_{name}_{backend}_{connection} (5+ parts) - // Base format: {version}_{name}_{backend}_{connection} (4 parts) - if (parts.length >= 5) { - // Remove schema prefix (first part) + // Schema-specific format: {schema}_{version}_{name}_{backend}_{connection} + // Base format: {version}_{name}_{backend}_{connection} + // Base migrations start with a version number (timestamp), schema-specific start with schema name + if (parts.length > 0 && !isVersionNumber(parts[0])) { + // First part is not a version number, so it's a schema prefix - remove it return parts.slice(1).join("_"); } return migrationID; @@ -218,81 +224,41 @@ export default function MigrationList() { // Helper function to check if migration ID is schema-specific const isSchemaSpecific = (migrationID: string): boolean => { const parts = migrationID.split("_"); - return parts.length >= 5; + // If first part is not a version number (timestamp), it's schema-specific + return parts.length > 0 && !isVersionNumber(parts[0]); }; - // Group migrations by base ID - const groupedMigrations = useMemo(() => { - const groups = new Map< - string, - { - base: MigrationListItem | null; - schemaSpecific: MigrationListItem[]; - } - >(); - - migrations.forEach((migration) => { - const baseID = getBaseMigrationID(migration.migration_id); - const isSpecific = isSchemaSpecific(migration.migration_id); - - if (!groups.has(baseID)) { - groups.set(baseID, { - base: null, - schemaSpecific: [], - }); - } - - const group = groups.get(baseID)!; - if (isSpecific) { - group.schemaSpecific.push(migration); - } else { - group.base = migration; - } + // Filter out schema-specific migrations - only show base migrations identified by BfM + const baseMigrations = useMemo(() => { + return migrations.filter((migration) => { + // Only include base migrations (not schema-specific) + return !isSchemaSpecific(migration.migration_id); }); - - return groups; }, [migrations]); - // Flatten grouped migrations for display - const flattenedMigrations = useMemo(() => { - const result: (MigrationListItem & { - isGroupHeader?: boolean; - groupKey?: string; - schemaCount?: number; - })[] = []; + // Calculate schema count for each base migration + const migrationsWithSchemaCount = useMemo(() => { + // Create a map to count schema-specific migrations for each base + const schemaCountMap = new Map(); - groupedMigrations.forEach((group, baseID) => { - // If there are schema-specific migrations, show grouped view - if (group.schemaSpecific.length > 0) { - // Use base migration if available, otherwise use first schema-specific as template - const baseMigration = group.base || group.schemaSpecific[0]; - const isExpanded = expandedGroups.has(baseID); - - // Add group header (base migration) - result.push({ - ...baseMigration, - migration_id: baseID, - isGroupHeader: true, - groupKey: baseID, - schemaCount: group.schemaSpecific.length, - }); - - // Add schema-specific migrations if expanded - if (isExpanded) { - group.schemaSpecific.forEach((migration) => { - result.push(migration); - }); - } - } else { - // No schema-specific migrations, just add the base migration - if (group.base) { - result.push(group.base); - } + migrations.forEach((migration) => { + if (isSchemaSpecific(migration.migration_id)) { + const baseID = getBaseMigrationID(migration.migration_id); + schemaCountMap.set(baseID, (schemaCountMap.get(baseID) || 0) + 1); } }); - return result; - }, [groupedMigrations, expandedGroups]); + // Add schema count to base migrations + return baseMigrations.map((migration) => ({ + ...migration, + schemaCount: schemaCountMap.get(migration.migration_id) || 0, + })); + }, [baseMigrations, migrations]); + + // Flatten migrations for display (no grouping needed, just base migrations) + const flattenedMigrations = useMemo(() => { + return migrationsWithSchemaCount; + }, [migrationsWithSchemaCount]); // Filter migrations based on search query const filteredMigrations = useMemo(() => { @@ -314,18 +280,6 @@ export default function MigrationList() { }); }, [flattenedMigrations, searchQuery]); - const toggleGroup = (groupKey: string) => { - setExpandedGroups((prev) => { - const newSet = new Set(prev); - if (newSet.has(groupKey)) { - newSet.delete(groupKey); - } else { - newSet.add(groupKey); - } - return newSet; - }); - }; - // Calculate pagination const totalPages = Math.ceil(filteredMigrations.length / itemsPerPage); const startIndex = (currentPage - 1) * itemsPerPage; @@ -501,8 +455,11 @@ export default function MigrationList() { // Rollback each selected migration for (const migration of selectedMigrationObjects) { try { + // Use migration schema if available + const schemas = migration.schema ? [migration.schema] : []; const response = await apiClient.rollbackMigration( migration.migration_id, + schemas, ); if (response.success) { @@ -1357,22 +1314,22 @@ export default function MigrationList() { /> - Migration ID + Version - {/* - Schema - */} - {/* + Name - */} + - Version + Connection Backend - Connection + Schema + + + Total Schemas Status @@ -1391,7 +1348,7 @@ export default function MigrationList() { {paginatedMigrations.length === 0 ? ( - + No migrations found @@ -1421,78 +1378,34 @@ export default function MigrationList() { /> - {migration.isGroupHeader ? ( -
- - - {migration.migration_id} - - - {migration.schemaCount} schema - {migration.schemaCount !== 1 ? "s" : ""} - -
- ) : ( -
- └─ - - {migration.migration_id} - - {migration.schema && ( - - {migration.schema} - - )} -
- )} + {migration.version} - {/* {migration.schema || '-'} - {migration.name || '-'} */} - {migration.version} + {migration.name || "-"} + + + {migration.connection || "-"} {migration.backend} - {migration.connection || "-"} + + {migration.schemaCount > 0 + ? "Multiple" + : migration.schema || "-"} + + + + + {migration.schemaCount || 0} + -
- - View - -
- - - -
- Execute from the Migration Details page -
-
-
-
+ + View + )) diff --git a/ffm/src/services/api.ts b/ffm/src/services/api.ts index b86a19d..29a16db 100644 --- a/ffm/src/services/api.ts +++ b/ffm/src/services/api.ts @@ -8,6 +8,7 @@ import type { MigrationDetailResponse, MigrationStatusResponse, MigrationHistoryResponse, + MigrationExecutionsResponse, RollbackResponse, HealthResponse, MigrationListFilters, @@ -207,6 +208,24 @@ class BFMApiClient { return response.data; } + async getMigrationExecutions( + migrationId: string, + ): Promise { + const response = await this.client.get( + `/v1/migrations/${migrationId}/executions`, + ); + return response.data; + } + + async getRecentExecutions( + limit: number = 10, + ): Promise { + const response = await this.client.get( + `/v1/migrations/executions/recent?limit=${limit}`, + ); + return response.data; + } + async migrate(request: MigrateRequest): Promise { const response = await this.client.post( "/v1/migrate", @@ -231,9 +250,13 @@ class BFMApiClient { return response.data; } - async rollbackMigration(migrationId: string): Promise { + async rollbackMigration( + migrationId: string, + schemas?: string[], + ): Promise { const response = await this.client.post( `/v1/migrations/${migrationId}/rollback`, + schemas ? { schemas } : {}, ); return response.data; } diff --git a/ffm/src/types/api.ts b/ffm/src/types/api.ts index 604fbd0..48b05af 100644 --- a/ffm/src/types/api.ts +++ b/ffm/src/types/api.ts @@ -109,9 +109,30 @@ export interface MigrationHistoryResponse { history: MigrationHistoryItem[]; } +export interface MigrationExecution { + id: number; + migration_id: string; + schema: string; + version: string; + connection: string; + backend: string; + status: string; + applied: boolean; + applied_at?: string; + created_at: string; + updated_at: string; +} + +export interface MigrationExecutionsResponse { + migration_id: string; + executions: MigrationExecution[]; +} + export interface RollbackResponse { success: boolean; message: string; + applied?: string[]; + skipped?: string[]; errors?: string[]; } From 10d387d5e3662ee02c10458d73df549ef5f921ee Mon Sep 17 00:00:00 2001 From: carlosrfjunior Date: Wed, 24 Dec 2025 00:30:42 -0300 Subject: [PATCH 5/6] chore: Dockerignore file --- .dockerignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ddf08f4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Exclude node_modules from Docker builds +ffm/node_modules +ffm/dist +ffm/.git +ffm/.DS_Store +ffm/*.log + +# Exclude other build artifacts +api/tmp +*.log +.DS_Store +.git +.gitignore From 00def0e64097085a4a03df9796f034bd9331c859 Mon Sep 17 00:00:00 2001 From: carlosrfjunior Date: Wed, 24 Dec 2025 09:16:01 -0300 Subject: [PATCH 6/6] chore: Log format --- api/cmd/server/main.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index 39f804f..b5f1eef 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "net" "net/http" @@ -160,12 +161,34 @@ func main() { // Initialize HTTP server router := gin.New() - // Custom logger middleware that skips health check endpoints + // Determine log format from environment variable (default to JSON) + logFormat := strings.ToLower(os.Getenv("BFM_LOG_FORMAT")) + useJSON := logFormat != "plaintext" && logFormat != "plain" && logFormat != "text" + + // Custom logger middleware that skips health check endpoints and supports JSON/plaintext router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { // Skip logging for health check endpoints if param.Path == "/health" || param.Path == "/api/v1/health" { return "" } + + if useJSON { + // JSON format + logEntry := map[string]interface{}{ + "timestamp": param.TimeStamp.Format("2006-01-02T15:04:05.000Z07:00"), + "status": param.StatusCode, + "latency": param.Latency.String(), + "client_ip": param.ClientIP, + "method": param.Method, + "path": param.Path, + "error": param.ErrorMessage, + } + if jsonBytes, err := json.Marshal(logEntry); err == nil { + return string(jsonBytes) + "\n" + } + } + + // Plaintext format (fallback or when explicitly set) return fmt.Sprintf("[GIN] %s | %3d | %13v | %15s | %-7s %s\n", param.TimeStamp.Format("2006/01/02 - 15:04:05"), param.StatusCode,