feat: added redux & ducks

This commit is contained in:
Home
2025-10-11 00:20:28 +03:00
parent de6f4bc6f9
commit 45c21cca82
43 changed files with 500 additions and 77 deletions

113
client/package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "client",
"version": "0.0.0",
"dependencies": {
"@reduxjs/toolkit": "^2.9.0",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router": "^7.9.3"
},
"devDependencies": {
@@ -1052,6 +1054,32 @@
"node": ">= 8"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"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
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
@@ -1367,6 +1395,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1440,7 +1480,7 @@
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1456,6 +1496,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@@ -2126,7 +2172,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2848,6 +2894,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3496,6 +3552,29 @@
"react": "^19.2.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3528,6 +3607,27 @@
}
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3979,6 +4079,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "7.1.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.8.tgz",

View File

@@ -13,10 +13,12 @@
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.9.0",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router": "^7.9.3"
},
"devDependencies": {

View File

@@ -1,5 +0,0 @@
import { api } from './api';
export interface Certificate {}
export const getCertificates = api.get<never, Certificate[]>('/certificates');

View File

@@ -1,4 +1 @@
export * from './certificates';
export * from './servers';
export * from './templates';
export * from './users';
export * from './api'

View File

@@ -1,5 +0,0 @@
import { api } from './api';
export interface Server {}
export const getServers = api.get<never, Server[]>('/servers');

View File

@@ -1,5 +0,0 @@
import { api } from './api';
export interface Template {}
export const getTemplates = api.get<never, Template[]>('/templates');

View File

@@ -1,5 +0,0 @@
import { api } from './api';
export interface User {}
export const getUsers = api.get<never, User[]>('/users');

View File

@@ -0,0 +1,2 @@
export * from './use-app-dispatch'
export * from './use-app-selector'

View File

@@ -0,0 +1,4 @@
import { useDispatch } from 'react-redux'
import type { AppDispatch } from '../../store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()

View File

@@ -0,0 +1,4 @@
import { useSelector } from 'react-redux'
import type { RootState } from '../../store'
export const useAppSelector = useSelector.withTypes<RootState>()

View File

@@ -0,0 +1,30 @@
import { certificateSlice } from './slice';
import { getCertificates } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getCertificatesState } from './selectors';
import type { RootState } from '../../../store';
const PREFFIX = 'certificates'
export const fetchCertificates = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getCertificatesState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(certificateSlice.actions.setLoading(true));
const response = await getCertificates().then(({ data }) => data);
dispatch(certificateSlice.actions.setUsers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(certificateSlice.actions.setError(message));
} finally {
dispatch(certificateSlice.actions.setLoading(false));
}
},
);

View File

@@ -0,0 +1,6 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { CertificateDTO } from './dto';
export const getCertificates = () =>
api.get<never, AxiosResponse<CertificateDTO[]>>('/certificates');

View File

@@ -0,0 +1 @@
export interface CertificateDTO {}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getCertificatesState = (state: RootState) => state.certificates;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { CertificateDTO } from './dto';
export interface CertificatesState {
loading: boolean;
certificates: CertificateDTO[]
error: null | string;
}
const initialState: CertificatesState = {
loading: false,
certificates: [],
error: null,
};
export const certificateSlice = createSlice({
name: 'certificates',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setUsers: (state, action: PayloadAction<CertificateDTO[]>) => {
state.certificates = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1 @@
export * from './duck'

View File

@@ -0,0 +1,4 @@
export * from './servers'
export * from './templates'
export * from './users'
export * from './certificates'

View File

@@ -0,0 +1,30 @@
import { serversSlice } from './slice';
import { getServers } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getServersState } from './selectors';
import type { RootState } from '../../../store';
const PREFFIX = 'servers'
export const fetchServers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getServersState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(serversSlice.actions.setLoading(true));
const response = await getServers().then(({ data }) => data);
dispatch(serversSlice.actions.setServers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(serversSlice.actions.setError(message));
} finally {
dispatch(serversSlice.actions.setLoading(false));
}
},
);

View File

@@ -0,0 +1,5 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { ServerDTO } from './dto';
export const getServers = () => api.get<never, AxiosResponse<ServerDTO[]>>('/servers');

View File

@@ -0,0 +1,7 @@
export interface ServerDTO {
id: number | string
name: string
hostname: string
grpc_port: number
status: string
}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getServersState = (state: RootState) => state.servers;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { ServerDTO } from './dto';
export interface ServersState {
loading: boolean;
servers: ServerDTO[];
error: null | string;
}
const initialState: ServersState = {
loading: false,
servers: [],
error: null,
};
export const serversSlice = createSlice({
name: 'servers',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setServers: (state, action: PayloadAction<ServerDTO[]>) => {
state.servers = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1 @@
export * from './duck'

View File

@@ -0,0 +1,29 @@
import { templatesSlice } from './slice';
import { getTemplates } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getTemplatesState } from './selectors';
import type { RootState } from '../../../store';
const PREFFIX = 'templates';
export const fetchTemplates = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getTemplatesState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(templatesSlice.actions.setLoading(true));
const response = await getTemplates().then(({ data }) => data);
dispatch(templatesSlice.actions.setTemplates(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(templatesSlice.actions.setError(message));
} finally {
dispatch(templatesSlice.actions.setLoading(false));
}
},
);

View File

@@ -0,0 +1,5 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { TemplateDTO } from './dto';
export const getTemplates = () => api.get<never, AxiosResponse<TemplateDTO[]>>('/templates');

View File

@@ -0,0 +1 @@
export interface TemplateDTO {}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getTemplatesState = (state: RootState) => state.templates;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { TemplateDTO } from './dto';
export interface TemplateState {
loading: boolean;
templates: TemplateDTO[];
error: null | string;
}
const initialState: TemplateState = {
loading: false,
templates: [],
error: null,
};
export const templatesSlice = createSlice({
name: 'templates',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setTemplates: (state, action: PayloadAction<TemplateDTO[]>) => {
state.templates = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1 @@
export * from './duck'

View File

@@ -0,0 +1,30 @@
import { usersSlice } from './slice';
import { getUsers } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getUsersState } from './selectors';
import type { RootState } from '../../../store';
const PREFFIX = 'users'
export const fetchUsers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getUsersState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(usersSlice.actions.setLoading(true));
const response = await getUsers().then(({ data }) => data);
dispatch(usersSlice.actions.setUsers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(usersSlice.actions.setError(message));
} finally {
dispatch(usersSlice.actions.setLoading(false));
}
},
);

View File

@@ -0,0 +1,5 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { UserDTO } from './dto';
export const getUsers = () => api.get<never, AxiosResponse<UserDTO[]>>('/users');

View File

@@ -0,0 +1,8 @@
export interface User {}
export interface UserDTO {
page: number
per_page: number
total: number
users: User[]
}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getUsersState = (state: RootState) => state.users;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { UserDTO, User } from './dto';
export interface UsersState {
loading: boolean;
users: User[]
error: null | string;
}
const initialState: UsersState = {
loading: false,
users: [],
error: null,
};
export const usersSlice = createSlice({
name: 'certificate',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setUsers: (state, action: PayloadAction<UserDTO[]>) => {
state.users = action.payload.users
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1 @@
export * from './duck'

View File

@@ -1,11 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { store } from './store/store';
import { Provider } from 'react-redux';
import { router } from './router';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
</StrictMode>,
);

View File

@@ -1,55 +1,32 @@
import type { RouteObject } from 'react-router';
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import {
getServers,
getTemplates,
getCertificates,
getUsers,
type Server,
type Template,
type Certificate,
type User,
} from '../../api';
import { useEffect, useState } from 'react';
export const loadDashboard = async () => {
try {
const [servers, templates, certificates, users] = await Promise.all([
getServers.then((data) => data),
getTemplates.then((data) => data),
getCertificates.then((data) => data),
getUsers.then((data) => data),
]);
return [servers, templates, certificates, users];
} catch (error) {
console.log(error);
alert('loading error');
}
};
fetchServers,
getServersState,
fetchTemplates,
getTemplatesState,
fetchUsers,
getUsersState,
getCertificatesState,
fetchCertificates,
} from '../../features';
export const Dashboard = () => {
const [servers, setServers] = useState<Server[] | undefined>(undefined);
const [templates, setTemplates] = useState<Template[] | undefined>(undefined);
const [certificates, setCertificates] = useState<Certificate[] | undefined>(
undefined,
);
const [users, setUsers] = useState<User[] | undefined>(undefined);
const dispatch = useAppDispatch();
const { loading: serverLoading, servers } = useAppSelector(getServersState);
const { loading: usersLoading, users } = useAppSelector(getUsersState);
const { loading: certificatesLoading, certificates } =
useAppSelector(getCertificatesState);
const { loading: templatesLoading, templates } =
useAppSelector(getTemplatesState);
useEffect(() => {
loadDashboard()
.then((res) => {
if (res) {
const [servers, templates, certificates, users] = res;
setServers(servers);
setTemplates(templates);
setCertificates(certificates);
setUsers(users);
}
})
.catch((e) => {
console.log(e);
});
}, []);
dispatch(fetchServers());
dispatch(fetchTemplates());
dispatch(fetchUsers());
dispatch(fetchCertificates());
}, [dispatch]);
return (
<div id="dashboard" className="tab-content active">
@@ -58,24 +35,30 @@ export const Dashboard = () => {
<p>
Servers:{' '}
<span id="serverCount">
{servers ? servers.length || 0 : 'Loading...'}
{serverLoading === true && 'Loading...'}
{servers && String(servers.length)}
</span>
</p>
<p>
Templates:{' '}
<span id="templateCount">
{templates ? templates.length || 0 : 'Loading...'}
{templatesLoading && 'Loading...'}
{templates && String(templates.length)}
</span>
</p>
<p>
Certificates:{' '}
<span id="certCount">
{certificates ? certificates.length || 0 : 'Loading...'}
{certificatesLoading && 'Loading...'}
{certificates && String(certificates.length)}
</span>
</p>
<p>
Users:{' '}
<span id="userCount">{users ? users.length || 0 : 'Loading...'}</span>
<span id="userCount">
{usersLoading && 'Loading...'}
{users && String(users.length)}
</span>
</p>
</div>
</div>

View File

@@ -0,0 +1 @@
export * from './store'

16
client/src/store/store.ts Normal file
View File

@@ -0,0 +1,16 @@
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import {serversSlice, templatesSlice, certificateSlice, usersSlice} from '../features'
export const store = configureStore({
reducer: combineReducers({
servers: serversSlice.reducer,
templates: templatesSlice.reducer,
users: usersSlice.reducer,
certificates: certificateSlice.reducer
}),
})
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch