diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index 3224619fc94..2280485e1da 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -1,17 +1,19 @@ -import dayjs from 'dayjs' +import { Search } from 'lucide-react' import { useState } from 'react' import { SupportCategories } from '@supabase/shared-types/out/constants' import { SupportLink } from 'components/interfaces/Support/SupportLink' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' +import { InlineLink } from 'components/ui/InlineLink' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Search } from 'lucide-react' +import { DOCS_URL } from 'lib/constants' +import { parseMigrationVersion } from 'lib/migration-utils' import { Button, Card, - Input, + cn, SidePanel, Table, TableBody, @@ -19,8 +21,12 @@ import { TableHead, TableHeader, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Admonition } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' import { MigrationsEmptyState } from './MigrationsEmptyState' const Migrations = () => { @@ -28,14 +34,20 @@ const Migrations = () => { const [selectedMigration, setSelectedMigration] = useState() const { data: project } = useSelectedProjectQuery() - const { data, isLoading, isSuccess, isError, error } = useMigrationsQuery({ + const { + data = [], + isLoading, + isSuccess, + isError, + error, + } = useMigrationsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) const migrations = search.length === 0 - ? data ?? [] - : data?.filter( + ? data + : data.filter( (migration) => migration.version.includes(search) || migration.name?.includes(search) ) ?? [] @@ -82,7 +94,7 @@ const Migrations = () => { {data.length <= 0 && } {data.length > 0 && ( - <> +
{ Version - Name - Inserted at (UTC) - + Name + Inserted at (UTC) + {migrations.length > 0 ? ( migrations.map((migration) => { - // [Joshen] LEFT OFF HERE - const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format( - 'DD MMM YYYY, HH:mm:ss' - ) + const versionDayjs = parseMigrationVersion(migration.version) + const insertedAt = versionDayjs + ? versionDayjs.format('DD MMM YYYY, HH:mm:ss') + : undefined return ( {migration.version} {migration?.name ?? 'Name not available'} - {insertedAt} + + + {insertedAt ?? 'Unknown'} + {!insertedAt && ( + + This migration was not generated via the{' '} + + Supabase CLI + {' '} + and hence we're unable to parse when this migration was + inserted at. + + )} + +
)} )} diff --git a/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx b/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx index 8742334d836..620a847bb75 100644 --- a/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx +++ b/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx @@ -6,8 +6,9 @@ import { useParams } from 'common' import { SingleStat } from 'components/ui/SingleStat' import { useBranchesQuery } from 'data/branches/branches-query' import { useBackupsQuery } from 'data/database/backups-query' -import { useMigrationsQuery } from 'data/database/migrations-query' +import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { parseMigrationVersion } from 'lib/migration-utils' import { cn, Skeleton } from 'ui' import { TimestampInfo } from 'ui-patterns' import { ServiceStatus } from './ServiceStatus' @@ -40,7 +41,10 @@ export const ActivityStats = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) - const latestMigration = useMemo(() => (migrationsData ?? [])[0], [migrationsData]) + const latestMigration = useMemo( + () => (migrationsData ?? [])[0], + [migrationsData] + ) const { data: backupsData, isLoading: isLoadingBackups } = useBackupsQuery({ projectRef: project?.ref, @@ -53,6 +57,21 @@ export const ActivityStats = () => { .sort((a, b) => new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf())[0] }, [backupsData]) + const [versionLabel, versionTimestamp] = useMemo(() => { + const version = latestMigration?.version + + const versionDayjs = parseMigrationVersion(version) + if (versionDayjs) { + return [versionDayjs.fromNow(), versionDayjs.toISOString()] + } + + return [undefined, undefined] + }, [latestMigration]) + + const hasValidVersion = versionLabel && versionTimestamp + + const versionLabelText = migrationsData && migrationsData.length > 0 ? 'Unknown' : 'No migrations' + return (
@@ -69,14 +88,14 @@ export const ActivityStats = () => { value={ isLoadingMigrations ? ( - ) : latestMigration ? ( + ) : hasValidVersion ? ( ) : ( -

No migrations

+

{versionLabelText}

) } /> diff --git a/apps/studio/lib/migration-utils.test.ts b/apps/studio/lib/migration-utils.test.ts new file mode 100644 index 00000000000..607d14da099 --- /dev/null +++ b/apps/studio/lib/migration-utils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { parseMigrationVersion } from './migration-utils' +import dayjs from 'dayjs' + +describe('parseMigrationVersion', () => { + it('should parse valid migration version in YYYYMMDDHHmmss format', () => { + const result = parseMigrationVersion('20231128095400') + + expect(result).not.toBeNull() + expect(result?.isValid()).toBe(true) + expect(result?.year()).toBe(2023) + expect(result?.month()).toBe(10) + expect(result?.date()).toBe(28) + expect(result?.hour()).toBe(9) + expect(result?.minute()).toBe(54) + expect(result?.second()).toBe(0) + }) + + it('should return undefined for invalid version format like "001"', () => { + const result = parseMigrationVersion('001') + + expect(result).toBeUndefined() + }) + + it('should return undefined for invalid version format like "002"', () => { + const result = parseMigrationVersion('002') + + expect(result).toBeUndefined() + }) + + it('should return undefined for invalid version format like "003"', () => { + const result = parseMigrationVersion('003') + + expect(result).toBeUndefined() + }) + + it('should return undefined for empty string', () => { + const result = parseMigrationVersion('') + + expect(result).toBeUndefined() + }) + + it('should return undefined for random string', () => { + const result = parseMigrationVersion('not-a-date') + + expect(result).toBeUndefined() + }) + + it('should return undefined for partial date format', () => { + const result = parseMigrationVersion('20231128') + + expect(result).toBeUndefined() + }) + + it('should handle edge case date values that dayjs can parse', () => { + // dayjs is lenient and will wrap invalid values to valid dates + // This is acceptable for our use case - the main goal is to reject + // non-date formats like "001", "002", etc. + const result = parseMigrationVersion('20231399000000') + + expect(result).not.toBeNull() + expect(result?.isValid()).toBe(true) + }) + + it('should allow chaining dayjs methods when valid', () => { + const result = parseMigrationVersion('20231128095400') + + expect(result?.fromNow()).toBeDefined() + expect(result?.toISOString()).toBeDefined() + expect(result?.format('DD MMM YYYY')).toBe('28 Nov 2023') + }) +}) diff --git a/apps/studio/lib/migration-utils.ts b/apps/studio/lib/migration-utils.ts new file mode 100644 index 00000000000..dceef55ab64 --- /dev/null +++ b/apps/studio/lib/migration-utils.ts @@ -0,0 +1,29 @@ +import dayjs, { Dayjs } from 'dayjs' + +/** + * Safely parses a migration version string as a date. + * Migration versions are typically in the format YYYYMMDDHHmmss (e.g., "20231128095400"). + * However, some projects may have custom version formats (e.g., "001", "002") that cannot be parsed as dates. + * + * @param version - Migration version string + * @returns Dayjs object if the version is a valid datetime, null otherwise + * + * @example + * const parsed = parseMigrationVersion('20231128095400') + * if (parsed) { + * console.log(parsed.fromNow()) // "2 hours ago" + * console.log(parsed.format('DD MMM YYYY')) // "28 Nov 2023" + * } + * + * @example + * const invalid = parseMigrationVersion('001') // returns null + */ +export function parseMigrationVersion(version: string | null | undefined): Dayjs | undefined { + if (!version) return undefined + + // Must contain only digits + if (!/^\d{14}$/.test(version)) return undefined + + const parsed = dayjs(version, 'YYYYMMDDHHmmss', true) + return parsed.isValid() ? parsed : undefined +}