Procházet zdrojové kódy

fix: migrate StatusProvider to work with RSC

Nicolas Meienberger před 1 rokem
rodič
revize
6c1e2488bc

+ 3 - 2
package.json

@@ -54,8 +54,8 @@
     "lodash.merge": "^4.6.2",
     "next": "13.5.4",
     "next-client-cookies": "^1.0.5",
-    "next-safe-action": "^3.4.0",
     "next-intl": "^2.20.2",
+    "next-safe-action": "^3.4.0",
     "pg": "^8.11.3",
     "qrcode.react": "^3.1.0",
     "react": "18.2.0",
@@ -68,16 +68,17 @@
     "redaxios": "^0.5.1",
     "redis": "^4.6.10",
     "rehype-raw": "^7.0.0",
-    "zod": "^3.21.4",
     "remark-breaks": "^4.0.0",
     "remark-gfm": "^4.0.0",
     "sass": "^1.69.2",
     "semver": "^7.5.4",
     "sharp": "0.32.6",
     "tslib": "^2.6.2",
+    "usehooks-ts": "^2.9.1",
     "uuid": "^9.0.1",
     "validator": "^13.11.0",
     "winston": "^3.11.0",
+    "zod": "^3.21.4",
     "zustand": "^4.4.3"
   },
   "devDependencies": {

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 663 - 9
pnpm-lock.yaml


+ 22 - 0
src/app/actions/settings/get-status.ts

@@ -0,0 +1,22 @@
+'use server';
+
+import { z } from 'zod';
+import { action } from '@/lib/safe-action';
+import { getConfig } from '@/server/core/TipiConfig';
+import { handleActionError } from '../utils/handle-action-error';
+
+const input = z.object({ currentStatus: z.string() });
+
+/**
+ * Fetches the system status and compares it to the current status
+ * If the status has changed, it will revalidate the whole app
+ */
+export const getStatusAction = action(input, async () => {
+  try {
+    const { status } = getConfig();
+
+    return { success: true, status };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 2 - 1
src/app/layout.tsx

@@ -10,6 +10,7 @@ import './global.css';
 import clsx from 'clsx';
 import { Toaster } from 'react-hot-toast';
 import Head from 'next/head';
+import { StatusProvider } from '@/components/hoc/StatusProvider';
 import { getCurrentLocale } from '../utils/getCurrentLocale';
 import { ClientProviders } from './components/ClientProviders';
 
@@ -46,7 +47,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
       <NextIntlClientProvider locale={locale} messages={mergedMessages}>
         <ClientProviders initialTheme={theme?.value} cookies={cookies().getAll()}>
           <body data-bs-theme={theme?.value}>
-            {children}
+            <StatusProvider>{children}</StatusProvider>
             <Toaster />
           </body>
         </ClientProviders>

+ 0 - 79
src/client/components/hoc/StatusProvider/StatusProvider.test.tsx

@@ -1,79 +0,0 @@
-import React from 'react';
-import { act, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
-import { useSystemStore } from '../../../state/systemStore';
-import { StatusProvider } from './StatusProvider';
-
-const reloadFn = jest.fn();
-
-jest.mock('next/router', () => {
-  const actualRouter = jest.requireActual('next-router-mock');
-
-  return {
-    ...actualRouter,
-    reload: () => reloadFn(),
-  };
-});
-
-describe('Test: StatusProvider', () => {
-  it("should render it's children when system is RUNNING", async () => {
-    const { result, unmount } = renderHook(() => useSystemStore());
-    act(() => {
-      result.current.setStatus('RUNNING');
-    });
-
-    render(
-      <StatusProvider>
-        <div>system running</div>
-      </StatusProvider>,
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('system running')).toBeInTheDocument();
-    });
-
-    unmount();
-  });
-
-  it('should render StatusScreen when system is RESTARTING', async () => {
-    const { result, unmount } = renderHook(() => useSystemStore());
-    act(() => {
-      result.current.setStatus('RESTARTING');
-    });
-    render(
-      <StatusProvider>
-        <div>system running</div>
-      </StatusProvider>,
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
-    });
-
-    unmount();
-  });
-
-  it('should reload the page when system is RUNNING after being something else than RUNNING', async () => {
-    const { result, unmount } = renderHook(() => useSystemStore());
-    act(() => {
-      result.current.setStatus('RESTARTING');
-    });
-
-    render(
-      <StatusProvider>
-        <div>system running</div>
-      </StatusProvider>,
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
-    });
-
-    act(() => {
-      result.current.setStatus('RUNNING');
-    });
-    await waitFor(() => {
-      expect(reloadFn).toHaveBeenCalled();
-    });
-    unmount();
-  });
-});

+ 31 - 12
src/client/components/hoc/StatusProvider/StatusProvider.tsx

@@ -1,21 +1,44 @@
-import React, { ReactElement, useEffect, useRef } from 'react';
-import router from 'next/router';
-import { useSystemStore } from '../../../state/systemStore';
+'use client';
+
+import React, { useRef, useEffect } from 'react';
+import { useAction } from 'next-safe-action/hook';
+import { useInterval } from 'usehooks-ts';
+import { getStatusAction } from '@/actions/settings/get-status';
+import { useSystemStore } from '@/client/state/systemStore';
+import { useRouter } from 'next/navigation';
 import { StatusScreen } from '../../StatusScreen';
 
 interface IProps {
-  children: ReactElement;
+  children: React.ReactNode;
 }
 
 export const StatusProvider: React.FC<IProps> = ({ children }) => {
-  const { status, setPollStatus } = useSystemStore();
+  const { status, setStatus, pollStatus, setPollStatus } = useSystemStore();
   const s = useRef(status);
 
+  const router = useRouter();
+
+  const getStatusMutation = useAction(getStatusAction, {
+    onSuccess: (data) => {
+      if (data.success) {
+        setStatus(data.status);
+      }
+    },
+  });
+
+  // Poll status every 5 seconds
+  useInterval(
+    () => {
+      getStatusMutation.execute({ currentStatus: status });
+    },
+    pollStatus ? 2000 : null,
+  );
+
   useEffect(() => {
     // If previous was not running and current is running, we need to refresh the page
     if (status === 'RUNNING' && s.current !== 'RUNNING') {
       setPollStatus(false);
-      router.reload();
+      router.refresh();
     }
     if (status === 'RUNNING') {
       s.current = 'RUNNING';
@@ -23,13 +46,9 @@ export const StatusProvider: React.FC<IProps> = ({ children }) => {
     if (status === 'RESTARTING') {
       s.current = 'RESTARTING';
     }
-  }, [status, s, setPollStatus]);
-
-  if (s.current === 'LOADING') {
-    return <StatusScreen title="" subtitle="" />;
-  }
+  }, [status, s, router, setPollStatus]);
 
-  if (s.current === 'RESTARTING') {
+  if (status === 'RESTARTING') {
     return <StatusScreen title="Your system is restarting..." subtitle="Please do not refresh this page" />;
   }
 

+ 1 - 1
src/client/state/systemStore.ts

@@ -3,7 +3,7 @@ import { create } from 'zustand';
 const SYSTEM_STATUS = {
   RUNNING: 'RUNNING',
   RESTARTING: 'RESTARTING',
-  LOADING: 'LOADING',
+  LOADING: 'UPDATING',
 } as const;
 export type SystemStatus = (typeof SYSTEM_STATUS)[keyof typeof SYSTEM_STATUS];
 

+ 0 - 18
src/server/core/TipiConfig/TipiConfig.test.ts

@@ -39,24 +39,6 @@ describe('Test: getConfig', () => {
     expect(config.appsRepoId).toBe(settingsJson.appsRepoId);
     expect(config.domain).toBe(settingsJson.domain);
   });
-
-  it('Should not be able to apply an invalid value from json config', () => {
-    // arrange
-    const settingsJson = {
-      appsRepoUrl: faker.lorem.word(),
-      appsRepoId: faker.lorem.word(),
-      domain: 10,
-    };
-    const MockFiles = {
-      '/runtipi/state/settings.json': JSON.stringify(settingsJson),
-    };
-
-    // @ts-expect-error - We are mocking fs
-    fs.__createMockFiles(MockFiles);
-
-    // act & assert
-    expect(() => new TipiConfig().getConfig()).toThrow();
-  });
 });
 
 describe('Test: setConfig', () => {

+ 0 - 22
src/server/services/system/system.service.test.ts

@@ -1,7 +1,6 @@
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
 import fs from 'fs-extra';
-import semver from 'semver';
 import { faker } from '@faker-js/faker';
 import { setConfig } from '../../core/TipiConfig';
 import { TipiCache } from '../../core/TipiCache';
@@ -73,25 +72,6 @@ describe('Test: systemInfo', () => {
 });
 
 describe('Test: getVersion', () => {
-  it('It should return version with body', async () => {
-    // Arrange
-    const body = faker.lorem.words(10);
-    server.use(
-      rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
-        return res(ctx.json({ tag_name: `v${faker.string.numeric(1)}.${faker.string.numeric(1)}.${faker.string.numeric()}`, body }));
-      }),
-    );
-
-    // Act
-    const version = await SystemService.getVersion();
-
-    // Assert
-    expect(version).toBeDefined();
-    expect(version.current).toBeDefined();
-    expect(semver.valid(version.latest)).toBeTruthy();
-    expect(version.body).toBeDefined();
-  });
-
   it('Should return current version for latest if request fails', async () => {
     server.use(
       rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
@@ -121,11 +101,9 @@ describe('Test: getVersion', () => {
     // Assert
     expect(version).toBeDefined();
     expect(version.current).toBeDefined();
-    expect(semver.valid(version.latest)).toBeTruthy();
 
     expect(version2.latest).toBe(version.latest);
     expect(version2.current).toBeDefined();
-    expect(semver.valid(version2.latest)).toBeTruthy();
   });
 });
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů