{
4 | onClose: (id: string) => void;
5 | }
6 |
7 | export const Toast = (props: ToastProps) => {
8 | const { id = '', message, type = 'success', duration = 10, onClose } = props;
9 |
10 | const className = classnames('toast', {
11 | 'info': type === 'info',
12 | 'success': type === 'success',
13 | 'error': type === 'error',
14 | 'warning': type === 'warning',
15 | });
16 |
17 | setTimeout(() => {
18 | onClose(id);
19 | }, duration * 1000);
20 |
21 | return (
22 | onClose(id)}>
23 | {message}
24 |
25 | )
26 | }
--------------------------------------------------------------------------------
/client/src/components/toast/ToastContainer.tsx:
--------------------------------------------------------------------------------
1 | import { Toast } from './Toast';
2 | import { useAppDispatch } from '@/hooks';
3 |
4 | import './Toast.css';
5 |
6 | type ToastContainerProps = {
7 | toasts: Toast[];
8 | };
9 |
10 | export const ToastContainer = ({toasts}:ToastContainerProps) => {
11 | const dispatch = useAppDispatch();
12 |
13 | const onClose = (id: string) => {
14 | // rather import action creator from store, keep it simple
15 | // and self-contained
16 | dispatch({
17 | type: 'app/closeToast',
18 | payload: id
19 | });
20 | };
21 |
22 | return (
23 |
24 | {toasts.map((toast, n) => )}
25 |
26 | );
27 | }
--------------------------------------------------------------------------------
/client/src/hooks/app.ts:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import type { TypedUseSelectorHook } from 'react-redux';
3 | import type { RootState, AppDispatch } from '@/app/store';
4 |
5 | import { toast } from '@/app/appSlice';
6 |
7 | export const useAppDispatch: () => AppDispatch = useDispatch;
8 | export const useAppSelector: TypedUseSelectorHook = useSelector;
9 |
10 | export const useToast = () => {
11 | const dispatch = useAppDispatch();
12 |
13 | return (message: string, type: ToastType) => {
14 | dispatch(toast(message, type));
15 | }
16 | }
--------------------------------------------------------------------------------
/client/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useAppDispatch, useAppSelector, useToast } from './app';
2 | export { useTelemetryHub } from './telemetryHub';
3 | export { useLatestValues } from './latestValues';
4 | export { useLoader } from './loader';
--------------------------------------------------------------------------------
/client/src/hooks/latestValues.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { selectLoading } from '@/app/appSlice';
3 | import { selectTelemetry, telemetryReceived } from '@/pages/telemetry/reducers/telemetrySlice';
4 | import { useAppDispatch, useAppSelector, useLoader } from '@/hooks';
5 |
6 | import * as API from '@/app/API';
7 |
8 | type LatestValueResult = [
9 | data: TelemetryData[],
10 | loading: boolean,
11 | errors: string[]
12 | ];
13 |
14 | export const useLatestValues = (): LatestValueResult => {
15 | const dispatch = useAppDispatch();
16 | const loading = useAppSelector(selectLoading);
17 | const data = useAppSelector(selectTelemetry);
18 | const loader = useLoader();
19 | const [errors, setErrors] = useState([]);
20 |
21 | useEffect(() => {
22 | async function getLatestValues() {
23 |
24 | loader(true);
25 |
26 | const response = await API.latestValues();
27 |
28 | loader(false);
29 |
30 | if (response.success) {
31 | dispatch(telemetryReceived(response.result));
32 | }
33 |
34 | if (response.errors.length) {
35 | setErrors(response.errors);
36 | }
37 | }
38 |
39 | getLatestValues();
40 |
41 | return () => {
42 | // clean up so we don't display stale data when user comes back
43 | dispatch(telemetryReceived([]));
44 | }
45 |
46 | }, []);
47 |
48 | return [data, loading, errors];
49 | }
--------------------------------------------------------------------------------
/client/src/hooks/loader.ts:
--------------------------------------------------------------------------------
1 | import { loading } from '@/app/appSlice';
2 | import { useAppDispatch } from '@/hooks';
3 |
4 | export const useLoader = () => {
5 | const dispatch = useAppDispatch();
6 |
7 | return (isLoading: boolean) => {
8 | dispatch(loading(isLoading));
9 | }
10 | }
--------------------------------------------------------------------------------
/client/src/hooks/telemetryHub.ts:
--------------------------------------------------------------------------------
1 | import { HubConnection } from '@microsoft/signalr';
2 | import { useEffect } from 'react';
3 | import { getSignalRConnection } from '@/utilities/signalr';
4 | import { useAppDispatch, useToast } from '@/hooks';
5 | import { telemetryReceived } from '@/pages/telemetry/reducers/telemetrySlice';
6 |
7 | export const useTelemetryHub = (connect: boolean) => {
8 | let connection: HubConnection;
9 | const dispatch = useAppDispatch();
10 | const toast = useToast();
11 | let received = false;
12 |
13 |
14 | useEffect(() => {
15 |
16 | if (!connect) {
17 | return;
18 | }
19 |
20 | async function subscribe() {
21 | connection = await getSignalRConnection('/signalr/telemetry');
22 |
23 | connection.on('subscribed', (response: string) => {
24 | console.log(response);
25 | toast(response, 'success');
26 | });
27 |
28 | connection.on('telemetry', (telemetryData: TelemetryData[]) => {
29 | if (!received) {
30 | received = true;
31 | toast('Receiving data...', 'info');
32 | }
33 | dispatch(telemetryReceived(telemetryData));
34 | });
35 |
36 | await connection.invoke('subscribe');
37 | }
38 |
39 | subscribe();
40 |
41 | return () => {
42 | if (connection) {
43 | connection.invoke('unsubscribe').then(() => {
44 | connection.off('subscribed');
45 | connection.off('telemetry');
46 |
47 | const message = 'Unsubscribed from Telemetry Hub';
48 | console.log(message);
49 |
50 | toast(message, 'success');
51 | });
52 | }
53 | }
54 |
55 | }, [connect]);
56 |
57 | }
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 | color-scheme: light dark;
6 | color: rgba(255, 255, 255, 0.87);
7 | font-synthesis: none;
8 | text-rendering: optimizeLegibility;
9 | -webkit-font-smoothing: antialiased;
10 | -moz-osx-font-smoothing: grayscale;
11 | -webkit-text-size-adjust: 100%;
12 | /* variables */
13 | --color-accent: #646cff;
14 | --color-background: #242424;
15 | --color-border: var(--color-accent);
16 | --color-hover: #535bf2; /* should calc from --color-accent */
17 | --color-info: #61c9e4;
18 | --color-warning: #e4be61;
19 | --color-error: #e47c61;
20 | --color-success: #88e451;
21 | --color-text-dark: #242424;
22 | --color-active: #FFF764;
23 | --color-input-background: #1a1a1a;
24 | }
25 |
26 | body,
27 | html {
28 | height: 100vh;
29 | margin: 0;
30 | padding: 0;
31 | box-sizing: border-box;
32 | background-color: var(--color-background);
33 | }
34 |
35 | body {
36 | display: flex;
37 | }
38 |
39 | a {
40 | font-weight: 500;
41 | color: var(--color-accent);
42 | text-decoration: inherit;
43 | }
44 |
45 | a:hover {
46 | color: var(--color-hover);
47 | }
48 |
49 | h1 {
50 | font-size: 3.2em;
51 | line-height: 1.1;
52 | }
53 |
54 | @media (prefers-color-scheme: light) {
55 | :root {
56 | color: #213547;
57 | background-color: #ffffff;
58 | }
59 | a:hover {
60 | color: #747bff;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import { BrowserRouter } from 'react-router-dom';
3 | import { Provider } from 'react-redux';
4 | import App from './App';
5 | import store from './app/store';
6 |
7 | import './index.css';
8 |
9 | createRoot(document.getElementById('root') as HTMLElement).render(
10 |
11 |
12 |
13 |
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/client/src/pages/auth/Auth.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
2 | import { Login } from './Login';
3 | import { Register } from './Register';
4 |
5 | export const Auth = () => {
6 | return (
7 |
8 |
10 |
11 |
12 | }>
13 | } />
14 | } />
15 | } />
16 |
17 |
18 | );
19 | }
20 |
21 |
--------------------------------------------------------------------------------
/client/src/pages/auth/Login.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { NavLink, useNavigate } from 'react-router-dom';
3 | import { useAppSelector, useAppDispatch, useToast } from '@/hooks';
4 | import { selectRegistered, loggedIn } from '@/app/appSlice';
5 | import * as API from '@/app/API';
6 | import { Button, Input } from '@/components';
7 |
8 | export const Login = () => {
9 | const dispatch = useAppDispatch();
10 | const navigate = useNavigate();
11 | const toast = useToast();
12 | const [email, setEmail] = useState('');
13 | const [password, setPassword] = useState('');
14 | const registered = useAppSelector(selectRegistered);
15 |
16 | const isValid = !!email && email.length > 1 && !!password && password.length > 1;
17 |
18 | const onSubmit = async () => {
19 | const response = await API.login(email, password);
20 |
21 | if (response.success) {
22 | dispatch(loggedIn(response.result!));
23 | navigate('/home');
24 | } else {
25 | response.errors.forEach(error => {
26 | toast(error, 'error');
27 | });
28 | }
29 | }
30 |
31 | return (
32 | <>
33 | Login
34 |
35 |
36 |
42 |
43 |
44 |
51 |
52 |
56 |
57 |
58 | {!registered &&
59 |
60 | Register
61 |
62 | }
63 | >
64 | );
65 | }
--------------------------------------------------------------------------------
/client/src/pages/auth/Register.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { NavLink, useNavigate } from 'react-router-dom';
3 | import { useAppDispatch, useToast } from '@/hooks';
4 | import { registered} from '@/app/appSlice';
5 | import { Button, Input } from '@/components';
6 | import * as API from '@/app/API';
7 |
8 | export const Register = () => {
9 | const dispatch = useAppDispatch();
10 | const navigate = useNavigate();
11 | const toast = useToast();
12 | const [email, setEmail] = useState('');
13 | const [userName, setUserName] = useState('');
14 | const [password, setPassword] = useState('');
15 | const [confirm, setConfirm] = useState('');
16 |
17 | const isValid = !!email?.length && !!password?.length && password === confirm;
18 |
19 | const onSubmit = async () => {
20 | const response = await API.register(email, userName, password);
21 |
22 | if (response.success) {
23 | // a litty dispatchy
24 | toast('Registration complete!', 'success');
25 | dispatch(registered(true));
26 | navigate('/auth/login');
27 | } else {
28 | response.errors.forEach(error => {
29 | toast(error, 'error');
30 | });
31 | }
32 | };
33 |
34 | return (
35 | <>
36 | Register
37 |
77 |
78 |
79 | Cancel
80 |
81 | >
82 | );
83 | }
--------------------------------------------------------------------------------
/client/src/pages/home/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useAppSelector, useAppDispatch } from '@/hooks';
2 | import { increment, selectCount } from './reducers/counterSlice';
3 | import { Button } from '@/components';
4 |
5 | export const Home = () => {
6 | // redux...just because
7 | const count = useAppSelector(selectCount);
8 | const dispatch = useAppDispatch();
9 |
10 | const onClick = () => {
11 | dispatch(increment());
12 | }
13 |
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/pages/home/reducers/counterSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import type { RootState } from '@/app/store';
3 |
4 | type CounterState = {
5 | value: number;
6 | };
7 |
8 | const initialState: CounterState = {
9 | value: 0
10 | };
11 |
12 | const counterSlice = createSlice({
13 | name: 'counter',
14 | initialState,
15 | reducers: {
16 | increment: (state) => {
17 | state.value += 1;
18 | }
19 | }
20 | });
21 |
22 | export const { increment } = counterSlice.actions;
23 |
24 | export const selectCount = (state: RootState) => state.counter.value;
25 |
26 | export default counterSlice.reducer;
--------------------------------------------------------------------------------
/client/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { Home } from './home/Home';
2 | export { Telemetry } from './telemetry/Telemetry';
3 | export { Auth } from './auth/Auth';
4 |
--------------------------------------------------------------------------------
/client/src/pages/telemetry/Telemetry.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { formatDateTime } from '@/utilities/datetime';
3 | import { useToast } from '@/hooks';
4 | import { Loader } from '@/components';
5 | import { useTelemetryHub, useLatestValues } from '@/hooks';
6 |
7 | export const Telemetry = () => {
8 | // a real world scenario is that telemetry may be coming in at
9 | // various intervals(every 30 secs, 5 minutes, etc) so we grab
10 | // the latest values from the db so that the user can see
11 | // something
12 | //
13 | // this seems a little wonkey but this feels more composable than using
14 | // the thunk here
15 | const toast = useToast();
16 | const [data, loading, errors ] = useLatestValues();
17 | const ready = data?.length;
18 |
19 | useEffect(() => {
20 | errors.forEach(error => toast(error, 'error'));
21 | }, [errors]);
22 |
23 | useTelemetryHub(!!ready);
24 |
25 | return (
26 | loading ?
27 | : ready ?
28 |
29 |
Telemetry Data
30 |
(updated every 5 sec)
31 |
32 |
33 |
34 |
35 | :
36 | )
37 |
38 | };
39 |
40 | const TelemetryTable = (props: { data: TelemetryData[] }) => {
41 | return (
42 |
43 |
44 |
45 | Timestamp |
46 | Sensor |
47 | Value |
48 | Unit |
49 |
50 |
51 |
52 | {props.data.map((data, i) =>
53 |
54 | {formatDateTime(data.timestamp)} |
55 | {data.sensor} |
56 | {data.value} |
57 | {data.unit} |
58 |
59 | )}
60 |
61 |
62 | )
63 | }
--------------------------------------------------------------------------------
/client/src/pages/telemetry/reducers/telemetrySlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import type { RootState } from '@/app/store';
3 |
4 | type TelemetryState = {
5 | data: TelemetryData[];
6 | };
7 |
8 | const initialState: TelemetryState = {
9 | data: []
10 | };
11 |
12 | const telemetrySlice = createSlice({
13 | name: 'telemetry',
14 | initialState,
15 | reducers: {
16 | telemetryReceived: (state, action) => {
17 | state.data = action.payload;
18 | }
19 | }
20 | });
21 |
22 | export const { telemetryReceived } = telemetrySlice.actions;
23 |
24 | export const selectTelemetry = (state: RootState) => state.telemetry.data;
25 |
26 | export default telemetrySlice.reducer;
--------------------------------------------------------------------------------
/client/src/pages/telemetry/reducers/telemetryThunk.ts:
--------------------------------------------------------------------------------
1 | import type { AppThunk } from '@/app/store';
2 | import * as API from '@/app/API';
3 | import { loading, toast } from '@/app/appSlice';
4 | import { telemetryReceived } from './telemetrySlice';
5 |
6 | export { telemetryReceived };
7 |
8 | // lasted values as thunk (not used here)
9 | //
10 | // use RTK Query in real life...
11 | // and don't dispatch toasts from thunks
12 | // and don't do this
13 | export const latestValues = (): AppThunk => {
14 | return async dispatch => {
15 | try {
16 |
17 | let loaded = false;
18 |
19 | // prevent loader flashing
20 | setTimeout(() => {
21 | if (!loaded) {
22 | dispatch(loading(true));
23 | }
24 | }, 500);
25 |
26 | const response = await API.latestValues();
27 |
28 | loaded = true;
29 |
30 | dispatch(loading(false));
31 |
32 | if (response.success) {
33 | dispatch(telemetryReceived(response.result));
34 | }
35 |
36 | response.errors.forEach(error => {
37 | dispatch(toast(error, 'error'));
38 | });
39 |
40 | } catch (err) {
41 | console.error(err);
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/client/src/types/app.d.ts:
--------------------------------------------------------------------------------
1 | type SuccessResult = {
2 | success: true;
3 | result: T;
4 | errors: string[];
5 | }
6 |
7 | type ErrorResult = {
8 | success: false;
9 | result: null;
10 | errors: string[];
11 | }
12 |
13 | type StandardResult = SuccessResult | ErrorResult;
14 | type StandardPromise = Promise>;
15 | type StandardApiCall = (...args: any) => StandardPromise;
16 |
17 | type ApiAsyncResult = {
18 | result: T;
19 | error: string | null;
20 | loading: boolean;
21 | ready: boolean;
22 | setResult: (result: T) => void;
23 | };
24 |
25 | type ToastType = 'success' | 'error' | 'info' | 'warning';
26 |
27 | type Toast = {
28 | id?: string;
29 | message: string;
30 | duration?: number;
31 | type: ToastType;
32 | }
33 |
34 | type LoginResponse = {
35 | email: string;
36 | userName: string;
37 | token: string;
38 | }
--------------------------------------------------------------------------------
/client/src/types/telemetry.d.ts:
--------------------------------------------------------------------------------
1 | type TelemetryData = {
2 | timestamp: string;
3 | sensor: string;
4 | value: number;
5 | unit: string;
6 | };
7 |
--------------------------------------------------------------------------------
/client/src/utilities/datetime.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 |
3 | export function formatDateTime(date: Date | string): string {
4 | return !!date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '';
5 | }
--------------------------------------------------------------------------------
/client/src/utilities/http.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import type { AxiosError, AxiosRequestConfig } from 'axios';
3 | import store from '@/app/store';
4 |
5 | function getToken() {
6 | const state = store.getState();
7 | return state.app.token;
8 | }
9 |
10 | function getErrorResult(error: string): ErrorResult {
11 | return {
12 | success: false,
13 | errors: [error],
14 | result: null
15 | }
16 | }
17 |
18 | function request(config: AxiosRequestConfig): StandardPromise {
19 | return axios(config).then(response => {
20 | return response.data as StandardResult
21 | }).catch((err: AxiosError,void>) => {
22 | if (err.response?.status === 401) {
23 | return getErrorResult('You are not authorized.');
24 | }
25 | return getErrorResult('An unknown error has occurred.') });
26 | }
27 |
28 | function config(method: string): AxiosRequestConfig {
29 | const token = getToken();
30 |
31 | return {
32 | method,
33 | headers: {
34 | 'Content-Type': 'application/json',
35 | ...(!!token && {'Authorization': `Bearer ${token}`})
36 | }
37 | }
38 | }
39 |
40 | export async function post(url: string, data: any): StandardPromise {
41 | return request({...config('POST'),
42 | url,
43 | data
44 | });
45 | }
46 |
47 | export async function get(url: string): StandardPromise {
48 | return request({...config('GET'),
49 | url,
50 | });
51 | }
52 |
53 |
--------------------------------------------------------------------------------
/client/src/utilities/signalr.ts:
--------------------------------------------------------------------------------
1 | import {
2 | JsonHubProtocol,
3 | HubConnection,
4 | HubConnectionState,
5 | HubConnectionBuilder,
6 | LogLevel,
7 | IHttpConnectionOptions,
8 | HttpTransportType
9 | } from '@microsoft/signalr';
10 |
11 | import store from '@/app/store';
12 |
13 | const isDev = process.env.NODE_ENV === 'development';
14 |
15 | const getToken = (): string => {
16 | const state = store.getState();
17 | return state.app.token!;
18 | }
19 |
20 | const startSignalRConnection = async (connection: HubConnection) => {
21 | try {
22 | await connection.start();
23 | console.assert(connection.state === HubConnectionState.Connected);
24 | console.log('SignalR connection established', connection.baseUrl);
25 | } catch (err) {
26 | console.assert(connection.state === HubConnectionState.Disconnected);
27 | console.error('SignalR Connection Error: ', err);
28 | setTimeout(() => startSignalRConnection(connection), 5000);
29 | }
30 | };
31 |
32 | export const getSignalRConnection = async (url: string) => {
33 |
34 | const options: IHttpConnectionOptions = {
35 | logMessageContent: isDev,
36 | logger: isDev ? LogLevel.Warning : LogLevel.Error,
37 | skipNegotiation: true,
38 | transport: HttpTransportType.WebSockets,
39 | accessTokenFactory: () => getToken()
40 | };
41 |
42 | console.log('SignalR: Creating new connection.');
43 |
44 | const connection = new HubConnectionBuilder()
45 | .withUrl(url, options)
46 | .withAutomaticReconnect()
47 | .withHubProtocol(new JsonHubProtocol())
48 | .configureLogging(LogLevel.Information)
49 | .build();
50 |
51 | // Note: to keep the connection open the serverTimeout should be
52 | // larger than the KeepAlive value that is set on the server
53 | //
54 | // keepAliveIntervalInMilliseconds default is 15000 and we are using default
55 | // serverTimeoutInMilliseconds default is 30000 and we are using 60000 set below
56 | connection.serverTimeoutInMilliseconds = 60000;
57 | connection.keepAliveIntervalInMilliseconds = 15000;
58 |
59 | // re-establish the connection if connection dropped
60 | connection.onclose(error => {
61 | console.assert(connection.state === HubConnectionState.Disconnected);
62 | if (!!error) {
63 | console.log('SignalR: connection was closed due to error.', error);
64 | } else {
65 | console.log('SignalR: connection was closed.');
66 | }
67 | });
68 |
69 | connection.onreconnecting(error => {
70 | console.assert(connection.state === HubConnectionState.Reconnecting);
71 | console.log('SignalR: connection lost due. Reconnecting...', error);
72 | });
73 |
74 | connection.onreconnected(connectionId => {
75 | console.assert(connection.state === HubConnectionState.Connected);
76 | console.log('SignalR: connection reestablished. Connected with connectionId', connectionId);
77 | });
78 |
79 | await startSignalRConnection(connection);
80 |
81 | return connection;
82 |
83 | };
84 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": [ "DOM", "DOM.Iterable", "ESNext" ],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "sourceMap": true,
18 | "jsx": "react-jsx",
19 | "baseUrl": "./src",
20 | "paths": {
21 | "@/*": [ "./*" ]
22 | }
23 | },
24 | "include": ["src"],
25 | "references": [{ "path": "./tsconfig.node.json" }]
26 | }
27 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 | import mkcert from 'vite-plugin-mkcert';
4 | import browserslistToEsbuild from 'browserslist-to-esbuild';
5 | import { fileURLToPath, URL } from 'url';
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | build: {
10 | target: browserslistToEsbuild()
11 | },
12 | //css: postcss /* loaded from postcss.config.cjs */
13 | plugins: [
14 | react(),
15 | mkcert()
16 | ],
17 | resolve: {
18 | alias: {
19 | '@': fileURLToPath(new URL('./src', import.meta.url))
20 | }
21 | },
22 | server: {
23 | https: true,
24 | strictPort: true,
25 | port: 3000,
26 | proxy: {
27 | '/api': {
28 | target: 'https://localhost:7200',
29 | secure: false
30 | },
31 | '/signalr': {
32 | target: 'wss://localhost:7200',
33 | ws: true,
34 | secure: false
35 | },
36 | }
37 | }
38 | })
--------------------------------------------------------------------------------
/docker-compose.postgres.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | db:
5 | image: postgres:15.2
6 | container_name: cvr-db
7 | restart: always
8 | environment:
9 | - POSTGRES_USER=admin
10 | - POSTGRES_PASSWORD=admin
11 | - POSTGRES_DB=postgres
12 | networks:
13 | - cvr
14 | ports:
15 | - "7432:5432"
16 | volumes:
17 | - db:/var/lib/postgresql/data
18 | - ./sql:/docker-entrypoint-initdb.d
19 | db-admin:
20 | image: dpage/pgadmin4
21 | container_name: cvr-db-admin
22 | networks:
23 | - cvr
24 | ports:
25 | - "7433:80"
26 | environment:
27 | - PGADMIN_DEFAULT_EMAIL=admin@admin.com
28 | - PGADMIN_DEFAULT_PASSWORD=admin
29 | volumes:
30 | - db-admin:/var/lib/pgadmin
31 |
32 | volumes:
33 | db:
34 | driver: local
35 | db-admin:
36 | driver: local
37 |
38 | networks:
39 | cvr:
40 | name: cvr_network
41 |
42 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | api:
5 | image: cvr-api:v1
6 | container_name: cvr-api
7 | restart: always
8 | environment:
9 | - ASPNETCORE_ENVIRONMENT=Development
10 | - ASPNETCORE_HTTPS_PORT=7200
11 | - ASPNETCORE_URLS=https://+:443
12 | - ASPNETCORE_Kestrel__Certificates__Default__Password=supersecret
13 | - ASPNETCORE_Kestrel__Certificates__Default__Path=/cert/API.pfx
14 | networks:
15 | - cvr
16 | ports:
17 | - "7200:443"
18 | volumes:
19 | # Windows using Linux containers
20 | # See https://github.com/dotnet/dotnet-docker/blob/main/samples/run-aspnetcore-https-development.md
21 | - ${USERPROFILE}\.aspnet\https:/cert/
22 | build:
23 | context: .
24 | dockerfile: Dockerfile
25 | depends_on:
26 | - db
27 | db:
28 | image: postgres:15.2
29 | container_name: cvr-db
30 | restart: always
31 | environment:
32 | - POSTGRES_USER=admin
33 | - POSTGRES_PASSWORD=admin
34 | - POSTGRES_DB=postgres
35 | networks:
36 | - cvr
37 | ports:
38 | - "7432:5432"
39 | volumes:
40 | - db:/var/lib/postgresql/data
41 | - ./sql:/docker-entrypoint-initdb.d
42 | db-admin:
43 | image: dpage/pgadmin4
44 | container_name: cvr-db-admin
45 | networks:
46 | - cvr
47 | ports:
48 | - "7433:80"
49 | environment:
50 | - PGADMIN_DEFAULT_EMAIL=admin@admin.com
51 | - PGADMIN_DEFAULT_PASSWORD=admin
52 | volumes:
53 | - db-admin:/var/lib/pgadmin
54 |
55 | volumes:
56 | db:
57 | driver: local
58 | db-admin:
59 | driver: local
60 |
61 | networks:
62 | cvr:
63 | name: cvr_network
64 |
65 |
--------------------------------------------------------------------------------
/sql/01-seed-identity.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
2 | "MigrationId" character varying(150) NOT NULL,
3 | "ProductVersion" character varying(32) NOT NULL,
4 | CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
5 | );
6 |
7 | START TRANSACTION;
8 |
9 | CREATE TABLE "AspNetUsers" (
10 | "Id" text NOT NULL,
11 | "UserName" character varying(256) NULL,
12 | "NormalizedUserName" character varying(256) NULL,
13 | "Email" character varying(256) NULL,
14 | "NormalizedEmail" character varying(256) NULL,
15 | "EmailConfirmed" boolean NOT NULL,
16 | "PasswordHash" text NULL,
17 | "SecurityStamp" text NULL,
18 | "ConcurrencyStamp" text NULL,
19 | "PhoneNumber" text NULL,
20 | "PhoneNumberConfirmed" boolean NOT NULL,
21 | "TwoFactorEnabled" boolean NOT NULL,
22 | "LockoutEnd" timestamp with time zone NULL,
23 | "LockoutEnabled" boolean NOT NULL,
24 | "AccessFailedCount" integer NOT NULL,
25 | CONSTRAINT "PK_AspNetUsers" PRIMARY KEY ("Id")
26 | );
27 |
28 | CREATE TABLE "AspNetUserClaims" (
29 | "Id" integer GENERATED BY DEFAULT AS IDENTITY,
30 | "UserId" text NOT NULL,
31 | "ClaimType" text NULL,
32 | "ClaimValue" text NULL,
33 | CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY ("Id"),
34 | CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
35 | );
36 |
37 | CREATE TABLE "AspNetUserLogins" (
38 | "LoginProvider" text NOT NULL,
39 | "ProviderKey" text NOT NULL,
40 | "ProviderDisplayName" text NULL,
41 | "UserId" text NOT NULL,
42 | CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"),
43 | CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
44 | );
45 |
46 | CREATE TABLE "AspNetUserTokens" (
47 | "UserId" text NOT NULL,
48 | "LoginProvider" text NOT NULL,
49 | "Name" text NOT NULL,
50 | "Value" text NULL,
51 | CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"),
52 | CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
53 | );
54 |
55 | CREATE INDEX "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");
56 |
57 | CREATE INDEX "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");
58 |
59 | CREATE INDEX "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");
60 |
61 | CREATE UNIQUE INDEX "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");
62 |
63 | INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
64 | VALUES ('20230320015352_initial', '7.0.4');
65 |
66 | COMMIT;
67 |
68 |
69 |
--------------------------------------------------------------------------------