refactor(web): add Redux state management and centralised routing

- Replace inline BrowserRouter with createBrowserRouter and lazy-loaded pages
- Add Redux store with auth-slice (token management) and redux-persist
- Create RequireAuth guard using Redux state via isAuthenticated selector
- Wire Layout logout to dispatch setToken("") clearing persisted auth
- Add ErrorPage component for route error handling
- Add EmptyLayout for unauthenticated pages
- Add VITE_REDUX_STORAGE env var for storage selection
This commit is contained in:
2026-07-07 15:17:49 +08:00
parent 3a6cd42361
commit 163f5641e5
13 changed files with 375 additions and 43 deletions
+4
View File
@@ -11,3 +11,7 @@
# Default: https://pipely.onixbyte.com/
# Development: http://localhost:8080
VITE_API_BASE_URL=https://pipely.onixbyte.com/
# Redux Persistent storage location
# Options: `local` (default), `session`
VITE_REDUX_STORAGE=local
+4
View File
@@ -14,12 +14,16 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-redux": "^9.3.0",
"react-router": "^8.1.0",
"react-router-dom": "^7.18.1",
"redux-persist": "^6.0.0",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
+141
View File
@@ -20,6 +20,9 @@ importers:
'@mui/material':
specifier: ^9.2.0
version: 9.2.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@reduxjs/toolkit':
specifier: ^2.12.0
version: 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
'@tailwindcss/vite':
specifier: ^4.3.2
version: 4.3.2(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0))
@@ -35,9 +38,18 @@ importers:
react-dom:
specifier: ^19.2.7
version: 19.2.7(react@19.2.7)
react-redux:
specifier: ^9.3.0
version: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
react-router:
specifier: ^8.1.0
version: 8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-router-dom:
specifier: ^7.18.1
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
redux-persist:
specifier: ^6.0.0
version: 6.0.0(react@19.2.7)(redux@5.0.1)
tailwindcss:
specifier: ^4.3.2
version: 4.3.2
@@ -499,6 +511,17 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.1.4':
resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -604,6 +627,12 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.3.2':
resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==}
@@ -735,6 +764,9 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -985,6 +1017,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -1445,6 +1480,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@11.1.11:
resolution: {integrity: sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2000,6 +2038,18 @@ packages:
react-is@19.2.7:
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.18.1:
resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
engines: {node: '>=20.0.0'}
@@ -2017,6 +2067,16 @@ packages:
react-dom:
optional: true
react-router@8.1.0:
resolution: {integrity: sha512-Mdfi61uObuvWNN9OhChOC0HV6YWOIfKRzEWOvCHRSuQg8IM+Nv10edaM/2HE8ZixBpUTdQbruyWqC3sDkkh9vw==}
engines: {node: '>=22.22.0'}
peerDependencies:
react: '>=19.2.7'
react-dom: '>=19.2.7'
peerDependenciesMeta:
react-dom:
optional: true
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
@@ -2031,10 +2091,30 @@ packages:
resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==}
engines: {node: '>= 4'}
redux-persist@6.0.0:
resolution: {integrity: sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==}
peerDependencies:
react: '>=16'
redux: '>4.0.0'
peerDependenciesMeta:
react:
optional: true
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2286,6 +2366,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2917,6 +3002,18 @@ snapshots:
'@popperjs/core@2.11.8': {}
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.11
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.2.0
optionalDependencies:
react: 19.2.7
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.1.4':
optional: true
@@ -2972,6 +3069,10 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.3.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -3077,6 +3178,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@types/validate-npm-package-name@4.0.2': {}
'@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
@@ -3343,6 +3446,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie-es@3.1.1: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -3822,6 +3927,8 @@ snapshots:
ignore@7.0.5: {}
immer@11.1.11: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4258,6 +4365,15 @@ snapshots:
react-is@19.2.7: {}
react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
redux: 5.0.1
react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
@@ -4272,6 +4388,13 @@ snapshots:
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
cookie-es: 3.1.1
react: 19.2.7
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 7.29.7
@@ -4291,8 +4414,22 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
redux-persist@6.0.0(react@19.2.7)(redux@5.0.1):
dependencies:
redux: 5.0.1
optionalDependencies:
react: 19.2.7
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
require-from-string@2.0.2: {}
reselect@5.2.0: {}
resolve-from@4.0.0: {}
resolve@1.22.12:
@@ -4584,6 +4721,10 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.7):
dependencies:
react: 19.2.7
util-deprecate@1.0.2: {}
validate-npm-package-name@7.0.2: {}
-35
View File
@@ -1,35 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
import Layout from "@/components/Layout"
import Login from "@/pages/Login"
import Dashboard from "@/pages/Dashboard"
import Artefacts from "@/pages/Artefacts"
import Deployments from "@/pages/Deployments"
import Users from "@/pages/Users"
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("token")
if (!token) return <Navigate to="/" replace />
return <>{children}</>
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/artefacts" element={<Artefacts />} />
<Route path="/deployments" element={<Deployments />} />
<Route path="/users" element={<Users />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom"
/**
* Empty layout component that provides minimal structure.
* Useful for pages that need full control over their layout.
*/
export default function EmptyLayout() {
return (
<div className="min-h-screen">
<Outlet />
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useRouteError, isRouteErrorResponse } from "react-router-dom"
import { Container, Typography, Button, Box } from "@mui/material"
export default function ErrorPage() {
const error = useRouteError()
let status = 500
let message = "Something went wrong."
if (isRouteErrorResponse(error)) {
status = error.status
message = error.statusText || message
} else if (error instanceof Error) {
message = error.message
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
}}
>
<Typography variant="h1" component="p" sx={{ mb: 2 }}>
{status}
</Typography>
<Typography variant="h5" component="p" color="text.secondary" sx={{ mb: 4 }}>
{message}
</Typography>
<Button variant="contained" onClick={() => window.location.assign("/")}>
Back to Dashboard
</Button>
</Box>
</Container>
)
}
+4
View File
@@ -1,4 +1,6 @@
import { NavLink, useNavigate, Outlet } from "react-router-dom"
import { useDispatch } from "react-redux"
import { setToken } from "@/store/auth-slice"
import {
Drawer,
List,
@@ -24,8 +26,10 @@ const drawerWidth = 240
export default function Layout() {
const navigate = useNavigate()
const dispatch = useDispatch()
function handleLogout() {
dispatch(setToken(""))
navigate("/")
}
+18 -6
View File
@@ -1,20 +1,32 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { Provider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"
import router from "@/router"
import store, { persistor } from "@/store"
import "./index.css"
import App from "./App.tsx"
const theme = createTheme({
palette: {
mode: 'light',
mode: "light",
},
})
/**
* Main application entry point.
* Sets up the React app with React Router.
*/
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
</PersistGate>
</Provider>
</StrictMode>
)
+72
View File
@@ -0,0 +1,72 @@
import type { ComponentType, ReactNode } from "react"
import { createBrowserRouter, Navigate } from "react-router-dom"
import ErrorPage from "@/components/ErrorPage"
import Layout from "@/components/Layout"
import EmptyLayout from "@/components/EmptyLayout"
import { isAuthenticated } from "@/store/auth-slice"
import { useAppSelector } from "@/store"
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
return async () => {
const module = await importer()
return { Component: module.default }
}
}
const hydrateFallbackElement = <div className="px-4 py-6 text-neutral-500">Loading...</div>
function RequireAuth({ children }: { children: ReactNode }) {
const authenticated = useAppSelector(isAuthenticated)
if (!authenticated) return <Navigate to="/login" replace />
return <>{children}</>
}
const router = createBrowserRouter(
[
{
path: "/",
element: <Navigate to="/dashboard" replace />,
},
{
element: (
<RequireAuth>
<Layout />
</RequireAuth>
),
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "dashboard",
lazy: lazy(() => import("@/pages/Dashboard")),
},
{
path: "artefacts",
lazy: lazy(() => import("@/pages/Artefacts")),
},
{
path: "deployments",
lazy: lazy(() => import("@/pages/Deployments")),
},
{
path: "users",
lazy: lazy(() => import("@/pages/Users")),
},
],
},
{
element: <EmptyLayout />,
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "login",
lazy: lazy(() => import("@/pages/Login")),
},
],
},
],
{ basename: "/" }
)
export default router
+24
View File
@@ -0,0 +1,24 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
export interface AuthState {
token: string
}
const initialState: AuthState = {
token: "",
}
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setToken(state, action: PayloadAction<string>) {
state.token = action.payload
},
},
})
export const { setToken } = authSlice.actions
export const authReducer = authSlice.reducer
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.token !== ""
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from "react-redux"
import type { AppDispatch, RootState } from "@/store"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+46
View File
@@ -0,0 +1,46 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit"
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist"
import createWebStorage from "redux-persist/es/storage/createWebStorage"
import { authReducer } from "./auth-slice"
const storage = createWebStorage(import.meta.env.VITE_REDUX_STORAGE ?? "local")
const persistConfig = {
key: "root",
storage,
whitelist: ["auth"],
}
const rootReducer = combineReducers({
auth: authReducer,
})
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
})
export const persistor = persistStore(store)
export default store
export type RootState = ReturnType<typeof rootReducer>
export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store
export { useAppSelector, useAppDispatch } from "./hooks"
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_REDUX_STORAGE?: "local" | "session"
}
interface ImportMeta {