unify all sql images to :latest (PG18)

Single version policy: ghcr.io/hanzoai/sql:latest everywhere.
No version pinning, no backwards compat, always latest stable.
This commit is contained in:
Hanzo Dev
2026-03-01 21:09:09 -08:00
parent 586538da24
commit 66f33743d7
9 changed files with 342 additions and 6 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- explorer-network
postgres:
image: postgres:14
image: ghcr.io/hanzoai/sql:latest
container_name: lux-postgres
restart: unless-stopped
environment:
@@ -9,7 +9,7 @@ export const accountSchema = yup
.string<AuthProvider>()
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', {
is: (value: boolean) => value === true,
then: (schema) => schema.oneOf([ 'auth0', 'dynamic' ]),
then: (schema) => schema.oneOf([ 'auth0', 'dynamic', 'oidc' ]),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not defined'),
}),
NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID: yup
@@ -19,4 +19,19 @@ export const accountSchema = yup
then: (schema) => schema.required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'dynamic\' '),
}),
NEXT_PUBLIC_OIDC_SERVER_URL: yup
.string()
.url()
.when('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER', {
is: (value: AuthProvider) => value === 'oidc',
then: (schema) => schema.required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_OIDC_SERVER_URL can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'oidc\''),
}),
NEXT_PUBLIC_OIDC_CLIENT_ID: yup
.string()
.when('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER', {
is: (value: AuthProvider) => value === 'oidc',
then: (schema) => schema.required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_OIDC_CLIENT_ID can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'oidc\''),
}),
});
+100
View File
@@ -0,0 +1,100 @@
# Chakra UI v3 → @hanzo/ui Migration Strategy
## Scale
| Category | Count |
|----------|-------|
| Files importing `@chakra-ui/react` directly | ~870 |
| Files consuming `toolkit/chakra/` wrappers | ~822 |
| Chakra wrapper files in `toolkit/chakra/` | 38 |
| Style-as-props usages (`w=`, `h=`, `bg=`, `p=`, etc.) | ~4,600 |
| Direct `Box`/`Flex`/`Text` usages (not via toolkit) | ~911 |
| `chakra.element` factory usages | ~470 (140 files) |
**Key insight:** 94% of component usage flows through `toolkit/chakra/` wrappers. Swapping wrapper internals is transparent to consumers.
## Fundamental Challenge
Chakra v3 = runtime CSS-in-JS + style-as-props
`@hanzo/ui` v5 = Tailwind + CVA + Radix primitives
These are **not** drop-in compatible. The ~4,600 style-as-props usages (`<Box p={4} bg="gray.100">`) have no direct `@hanzo/ui` equivalent.
## Recommended Strategy: Shim Approach
Create lightweight shim components (`Box`, `Flex`, `Text`) that accept Chakra-style props but render plain HTML with inline styles. This eliminates the Chakra/Emotion runtime (~45kB gzipped) without requiring a full Tailwind conversion.
## Phase Plan
### Phase 1 — Add @hanzo/ui (23 days)
- Install `@hanzo/ui` alongside Chakra
- Verify no peer-dependency conflicts
- Use `@hanzo/ui` for **new** components only
- No breaking changes; both libraries coexist
### Phase 2 — Swap toolkit/chakra/ internals (57 days)
- For each of the 38 wrapper files in `toolkit/chakra/`:
- Identify the `@hanzo/ui` equivalent (Button, Dialog, Table, etc.)
- Swap the internal implementation
- Keep the exported API identical (prop names, behavior)
- Consumers see zero changes
- **Testing:** existing Playwright/Vitest suite must pass after each file
### Phase 3 — Primitive shims (35 days)
Create `toolkit/shims/`:
```tsx
// Box.tsx — accepts Chakra props, renders div with inline styles
// Flex.tsx — Box with display:flex
// Text.tsx — renders span/p with mapped typography props
// Grid.tsx, Stack.tsx, etc.
```
- Replace direct `@chakra-ui/react` imports of primitives with shims
- Style-as-props mapped to inline CSS via prop → CSS property table
- Run `grep -r "from '@chakra-ui/react'" --include="*.tsx"` to track progress
### Phase 4 — Complex components (1014 days)
Migrate in order of usage frequency:
1. Modal → `@hanzo/ui` Dialog
2. Tabs → `@hanzo/ui` Tabs
3. Table → `@hanzo/ui` Table
4. Tooltip → `@hanzo/ui` Tooltip
5. Input/Form — already wrapped in `toolkit/chakra/input.tsx`
6. Menu → `@hanzo/ui` DropdownMenu (Radix-based)
### Phase 5 — Remove Chakra (23 days)
- Remove `@chakra-ui/react`, `@emotion/react`, `@emotion/styled` from `package.json`
- Remove `ChakraProvider` from `pages/_app.tsx`
- Clean up any remaining `chakra.*` factory calls
- Verify build passes with `pnpm build`
## Risk Assessment
| Risk | Mitigation |
|------|-----------|
| Color mode flicker after removing ChakraProvider | Already using `next-themes` — color mode survives |
| Style-as-props with complex Chakra tokens (`colorScheme`, `size`) | Map tokens in shim layer |
| SSR hydration mismatches | Inline styles are SSR-safe; no CSS-in-JS hydration |
| Breaking Playwright tests | Run full suite after each phase before merging |
| Third-party components using Chakra internally | Use `@chakra-ui/react` as a peer for those; don't remove globally until all are replaced |
## Estimated Effort
| Phase | Effort |
|-------|--------|
| Phase 1: Add @hanzo/ui | 23 days |
| Phase 2: toolkit/chakra/ internals | 57 days |
| Phase 3: Primitive shims | 35 days |
| Phase 4: Complex components | 1014 days |
| Phase 5: Remove Chakra | 23 days |
| **Total** | **2232 days** |
## Implementation Notes
- The `toolkit/theme/foundations/semanticTokens.ts` token system maps 1:1 to CSS custom properties — these survive unchanged as they're already framework-agnostic
- `next-themes` is used for dark/light mode (not Chakra's built-in) — no migration needed here
- `toolkit/chakra/` is the single choke point; migrating it in Phase 2 buys the most leverage with least risk
- Start with the most-used wrappers: `Button`, `Box`, `Text`, `Flex`, `Input`, `Modal`
## Decision: Start Date
Phase 1 can begin immediately. Recommended: start after current sprint's feature work is stable in production.
+2
View File
@@ -4,6 +4,7 @@ export { getPChain, getInfo, getHealth } from './client';
export { useCurrentValidators } from './useCurrentValidators';
export { useBlockchains } from './useBlockchains';
export { useSubnets } from './useSubnets';
export { useChainHeights } from './useChainHeights';
export type {
PChainValidator,
@@ -18,3 +19,4 @@ export type {
} from './types';
export type { UseCurrentValidatorsResult } from './useCurrentValidators';
export type { UseChainHeightsResult } from './useChainHeights';
+67
View File
@@ -0,0 +1,67 @@
// React Query hook for fetching live block heights for P-chain and C-chain.
import { useQuery } from '@tanstack/react-query';
import { getEnvValue } from 'configs/app/utils';
const HEIGHTS_STALE_TIME_MS = 15_000;
const HEIGHTS_QUERY_KEY = 'pchain:chainHeights' as const;
const CCHAIN_RPC_PATTERN = /\/ext\/bc\/C\/rpc$/;
function getApiBase(): string {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL') ?? '';
return rpcUrl.replace(CCHAIN_RPC_PATTERN, '');
}
async function fetchChainHeights(): Promise<{ pChain: number; cChain: number }> {
const base = getApiBase();
const [ pRes, cRes ] = await Promise.allSettled([
fetch(`${ base }/ext/bc/P`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'platform.getHeight', params: {}, id: 1 }),
}),
fetch(`${ base }/ext/bc/C/rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_blockNumber', params: [], id: 2 }),
}),
]);
let pChain = 0;
let cChain = 0;
if (pRes.status === 'fulfilled' && pRes.value.ok) {
const data = await pRes.value.json() as { result?: { height?: string } };
pChain = data.result?.height ? parseInt(data.result.height, 10) : 0;
}
if (cRes.status === 'fulfilled' && cRes.value.ok) {
const data = await cRes.value.json() as { result?: string };
cChain = data.result ? parseInt(data.result, 16) : 0;
}
return { pChain, cChain };
}
export interface UseChainHeightsResult {
readonly pChainHeight: number;
readonly cChainHeight: number;
readonly isLoading: boolean;
}
export function useChainHeights(): UseChainHeightsResult {
const query = useQuery({
queryKey: [ HEIGHTS_QUERY_KEY ],
queryFn: fetchChainHeights,
staleTime: HEIGHTS_STALE_TIME_MS,
refetchInterval: HEIGHTS_STALE_TIME_MS,
});
return {
pChainHeight: query.data?.pChain ?? 0,
cChainHeight: query.data?.cChain ?? 0,
isLoading: query.isLoading,
};
}
+18 -2
View File
@@ -1,7 +1,7 @@
import { Box, Flex, Grid, Text } from '@chakra-ui/react';
import React from 'react';
import { useBlockchains, useCurrentValidators } from 'lib/api/pchain';
import { useBlockchains, useChainHeights, useCurrentValidators } from 'lib/api/pchain';
import type { PChainBlockchain } from 'lib/api/pchain';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Heading } from 'toolkit/chakra/heading';
@@ -80,9 +80,11 @@ interface ChainRowProps {
readonly vm: string;
readonly href: string | undefined;
readonly tier?: string;
readonly height?: number;
readonly heightLoading?: boolean;
}
const ChainRow = ({ name, fullName, vm, href, tier }: ChainRowProps) => {
const ChainRow = ({ name, fullName, vm, href, tier, height, heightLoading }: ChainRowProps) => {
const content = (
<Flex
align="center"
@@ -103,6 +105,13 @@ const ChainRow = ({ name, fullName, vm, href, tier }: ChainRowProps) => {
</Text>
</Flex>
<Flex align="center" gap={ 1.5 }>
{ height !== undefined && (
<Skeleton loading={ heightLoading }>
<Text fontSize="xs" color="text.secondary" fontFamily="mono">
{ height > 0 ? `#${ height.toLocaleString() }` : '' }
</Text>
</Skeleton>
) }
{ tier && <Tag size="sm" variant="subtle">{ tier }</Tag> }
<Tag size="sm" variant="subtle">{ vm }</Tag>
</Flex>
@@ -200,6 +209,7 @@ const NetworkOverview = () => {
const isMobile = useIsMobile();
const { stats, isLoading: validatorsLoading } = useCurrentValidators();
const { blockchains, isLoading: chainsLoading } = useBlockchains();
const { pChainHeight, cChainHeight, isLoading: heightsLoading } = useChainHeights();
const l1Chains = React.useMemo(
() => blockchains.filter((c) => c.subnetID !== PRIMARY_NETWORK_ID),
@@ -228,6 +238,10 @@ const NetworkOverview = () => {
flexWrap="wrap"
overflow="hidden"
>
<Metric label="C-Chain" value={ cChainHeight > 0 ? `#${ cChainHeight.toLocaleString() }` : '—' } isLoading={ heightsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="P-Chain" value={ pChainHeight > 0 ? `#${ pChainHeight.toLocaleString() }` : '—' } isLoading={ heightsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Chains" value={ String(totalChains) } isLoading={ isLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Validators" value={ String(stats.validatorCount) } isLoading={ isLoading }/>
@@ -276,6 +290,8 @@ const NetworkOverview = () => {
fullName={ chain.fullName }
vm={ chain.vm }
href={ chain.href }
height={ chain.id === 'C' ? cChainHeight : chain.id === 'P' ? pChainHeight : undefined }
heightLoading={ heightsLoading }
/>
)) }
</Flex>
+3
View File
@@ -7,6 +7,7 @@ import { useIsSticky } from 'toolkit/hooks/useIsSticky';
import RewardsButton from 'ui/rewards/RewardsButton';
import NetworkIcon from 'ui/snippets/networkLogo/NetworkIcon';
import UserProfileAuth0 from 'ui/snippets/user/profile/auth0/UserProfileMobile';
import UserProfileOidc from 'ui/snippets/user/profile/oidc/UserProfileDesktop';
import UserWalletMobile from 'ui/snippets/user/wallet/UserWalletMobile';
import RollupStageBadge from '../navigation/RollupStageBadge';
@@ -33,6 +34,8 @@ const HeaderMobile = ({ hideSearchButton, onGoToSearchResults }: Props) => {
return <UserProfileAuth0/>;
case 'dynamic':
return <UserProfileDynamic/>;
case 'oidc':
return <UserProfileOidc/>;
default:
break;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import dynamic from 'next/dynamic';
import config from 'configs/app';
import UserProfileAuth0 from 'ui/snippets/user/profile/auth0/UserProfileDesktop';
import UserProfileOidc from 'ui/snippets/user/profile/oidc/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
const UserProfileDynamic = dynamic(() => import('ui/snippets/user/profile/dynamic/UserProfile'), { ssr: false });
@@ -20,8 +21,8 @@ const UserProfileDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) =>
return <UserProfileAuth0 buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
case 'dynamic':
return <UserProfileDynamic buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
default:
break;
case 'oidc':
return <UserProfileOidc buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
}
}
// Always render the wallet/settings menu — it handles both wallet and settings
@@ -0,0 +1,132 @@
// OIDC-specific user profile desktop component.
// Login redirects to lux.id; shows name/email + logout when authenticated.
import { Box, Flex } from '@chakra-ui/react';
import type { ButtonProps } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import shortenString from 'lib/shortenString';
import { Button } from 'toolkit/chakra/button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { Separator } from 'toolkit/chakra/separator';
import { Link } from 'toolkit/chakra/link';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import IconSvg from 'ui/shared/IconSvg';
import useLogout from 'ui/snippets/auth/useLogout';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
const REDIRECT_URI_PATH = '/auth/callback';
function buildOidcLoginUrl(): string {
const feature = config.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
return '';
}
const { serverUrl, clientId } = feature.oidc;
const redirectUri = `${ window.location.origin }${ REDIRECT_URI_PATH }`;
const state = crypto.randomUUID();
sessionStorage.setItem('oidc_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: 'openid profile email',
state,
});
return `${ serverUrl }/login/oauth/authorize?${ params.toString() }`;
}
interface Props {
buttonSize?: ButtonProps['size'];
buttonVariant?: ButtonProps['variant'];
}
const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const profileQuery = useProfileQuery();
const profileMenu = useDisclosure();
const logout = useLogout();
const handleButtonClick = React.useCallback(() => {
if (profileQuery.data) {
profileMenu.onOpen();
return;
}
const url = buildOidcLoginUrl();
if (url) {
window.location.href = url;
}
}, [ profileQuery.data, profileMenu ]);
const handleLogout = React.useCallback(async() => {
profileMenu.onClose();
await logout();
}, [ logout, profileMenu ]);
const handleMenuOpenChange = React.useCallback(({ open }: { open: boolean }) => {
!open && profileMenu.onOpenChange({ open });
}, [ profileMenu ]);
const isLoading = profileQuery.isLoading;
const data = profileQuery.data;
const buttonLabel = (() => {
if (isLoading) {
return null;
}
if (data) {
const handle = data.email ? data.email.split('@')[0] : 'My account';
return (
<Flex align="center" gap={ 2 }>
<IconSvg name="profile" boxSize={ 5 }/>
<Box display={{ base: 'none', md: 'block' }}>{ handle }</Box>
</Flex>
);
}
return 'Log in';
})();
return (
<PopoverRoot
positioning={{ placement: 'bottom-end' }}
open={ profileMenu.open }
onOpenChange={ handleMenuOpenChange }
>
<PopoverTrigger>
<Button
size={ buttonSize }
variant={ buttonVariant }
onClick={ handleButtonClick }
loading={ isLoading }
selected={ Boolean(data) }
px={{ base: 2.5, lg: 3 }}
fontWeight={ data ? 700 : 600 }
>
{ buttonLabel }
</Button>
</PopoverTrigger>
{ data && profileMenu.open && (
<PopoverContent w="220px">
<PopoverBody>
<Flex direction="column" gap={ 2 } py={ 1 }>
<Box fontSize="sm" color="text.secondary" px={ 1 }>
{ data.email ?? shortenString(String(data.id ?? '')) }
</Box>
<Separator/>
<Link href="/auth/profile" textStyle="sm" color="text.primary" px={ 1 }>
My profile
</Link>
<Button variant="ghost" size="sm" onClick={ handleLogout } colorPalette="red" justifyContent="flex-start" px={ 1 }>
Sign out
</Button>
</Flex>
</PopoverBody>
</PopoverContent>
) }
</PopoverRoot>
);
};
export default React.memo(UserProfileOidc);