mirror of
https://github.com/meienberger/runtipi.git
synced 2024-09-11 12:15:48 +03:00
feat: app backups (#1488)
* feat: app backups * refactor: rename backing up to backing_up * fix: fix eslint errors * chore: merge migrations into one * refactor(backup): allow multiple backups and restore from file * chore: remove backups from update settings modal * feat: create table pagination high level component * feat: app backups API * chore: migrate to @tanstack/react-query@v5 * feat(client): app backups tab and table * feat(worker): add archive manager to create and extract tarballs * feat(backups): restore, backups fixes and imrpovements * refactor: isolate app backup components in their own class/folder * feat(backup): delete app backup * fix(app-backups): bot suggestions --------- Co-authored-by: Nicolas Meienberger <github@thisprops.com>
This commit is contained in:
parent
c89c17b08c
commit
da98f9a65b
1
.gitignore
vendored
1
.gitignore
vendored
@ -55,6 +55,7 @@ node_modules/
|
||||
/repos/
|
||||
/apps/
|
||||
/traefik/
|
||||
/backups/
|
||||
|
||||
# media folder
|
||||
media
|
||||
|
@ -80,6 +80,7 @@ services:
|
||||
- ${RUNTIPI_TRAEFIK_PATH:-.}/traefik:/data/traefik
|
||||
- ${RUNTIPI_USER_CONFIG_PATH:-.}/user-config:/data/user-config
|
||||
- ${RUNTIPI_APP_DATA_PATH:-.}/app-data:/app-data
|
||||
- ${RUNTIPI_BACKUPS_PATH:-.}/backups:/data/backups
|
||||
# Static
|
||||
- ./.env:/data/.env
|
||||
- ./docker-compose.dev.yml:/data/docker-compose.yml
|
||||
|
@ -87,6 +87,7 @@ services:
|
||||
- ${RUNTIPI_TRAEFIK_PATH:-.}/traefik:/data/traefik
|
||||
- ${RUNTIPI_USER_CONFIG_PATH:-.}/user-config:/data/user-config
|
||||
- ${RUNTIPI_APP_DATA_PATH:-.}/app-data:/app-data
|
||||
- ${RUNTIPI_BACKUPS_PATH:-.}/backups:/data/backups
|
||||
# Static
|
||||
- ./.env:/data/.env
|
||||
- ./docker-compose.prod.yml:/data/docker-compose.yml
|
||||
|
@ -43,6 +43,7 @@
|
||||
"@sentry/nextjs": "^8.19.0",
|
||||
"@tabler/core": "1.0.0-beta20",
|
||||
"@tabler/icons-react": "^3.11.0",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"argon2": "^0.40.3",
|
||||
"bullmq": "^5.10.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@ -66,7 +67,6 @@
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-query": "^3.39.3",
|
||||
"react-timezone-select": "^3.2.5",
|
||||
"react-tooltip": "^5.27.1",
|
||||
"redaxios": "^0.5.1",
|
||||
|
@ -8,13 +8,15 @@ import { appInfoSchema } from '../../../../schemas/app-schemas';
|
||||
// Lower level data access class for apps
|
||||
export class DataAccessApp {
|
||||
private dataDir: string;
|
||||
private appDataDir: string;
|
||||
private appsRepoId: string;
|
||||
private logger: FileLogger;
|
||||
|
||||
constructor(dataDir: string, appsRepoId: string) {
|
||||
this.dataDir = dataDir;
|
||||
this.appsRepoId = appsRepoId;
|
||||
this.logger = new FileLogger('data-access-app', path.join(dataDir, 'logs'), true);
|
||||
constructor(params: { dataDir: string; appDataDir: string; appsRepoId: string }) {
|
||||
this.dataDir = params.dataDir;
|
||||
this.appDataDir = params.appDataDir;
|
||||
this.appsRepoId = params.appsRepoId;
|
||||
this.logger = new FileLogger('data-access-app', path.join(params.dataDir, 'logs'), true);
|
||||
}
|
||||
|
||||
private getInstalledAppsFolder() {
|
||||
@ -118,4 +120,36 @@ export class DataAccessApp {
|
||||
|
||||
return appsDir.filter((app) => !skippedFiles.includes(app));
|
||||
}
|
||||
|
||||
public async listBackupsByAppId(appId: string) {
|
||||
const backupsDir = path.join(this.dataDir, 'backups', sanitizePath(appId));
|
||||
|
||||
if (!(await pathExists(backupsDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const list = await fs.promises.readdir(backupsDir);
|
||||
|
||||
const backups = await Promise.all(
|
||||
list.map(async (backup) => {
|
||||
const stats = await fs.promises.stat(path.join(backupsDir, backup));
|
||||
return { id: backup, size: stats.size, date: stats.mtime.toISOString() };
|
||||
}),
|
||||
);
|
||||
|
||||
return backups;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error listing backups for app ${appId}: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteBackup(appId: string, filename: string) {
|
||||
const backupPath = path.join(this.dataDir, 'backups', sanitizePath(appId), filename);
|
||||
|
||||
if (await pathExists(backupPath)) {
|
||||
await fs.promises.unlink(backupPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,9 @@ import { DataAccessApp } from '../data-access/data-access-app';
|
||||
export class AppDataService {
|
||||
private dataAccessApp: DataAccessApp;
|
||||
|
||||
constructor(dataDir: string, appsRepoId: string) {
|
||||
this.dataAccessApp = new DataAccessApp(dataDir, appsRepoId);
|
||||
constructor(params: { dataDir: string; appDataDir: string; appsRepoId: string }) {
|
||||
const { dataDir, appDataDir, appsRepoId } = params;
|
||||
this.dataAccessApp = new DataAccessApp({ dataDir, appDataDir, appsRepoId });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -16,16 +17,13 @@ export class AppDataService {
|
||||
* If an error occurs during the process, it logs the error message and throws an error.
|
||||
*
|
||||
* @param {string} id - The app id.
|
||||
* @param {App['status']} [status] - The app status.
|
||||
*/
|
||||
public async getAppInfoFromInstalledOrAppStore(id: string, status?: string) {
|
||||
const installed = typeof status !== 'undefined' && status !== 'missing';
|
||||
|
||||
if (installed) {
|
||||
return this.dataAccessApp.getInstalledAppInfo(id);
|
||||
} else {
|
||||
public async getAppInfoFromInstalledOrAppStore(id: string) {
|
||||
const info = await this.dataAccessApp.getInstalledAppInfo(id);
|
||||
if (!info) {
|
||||
return this.dataAccessApp.getAppInfoFromAppStore(id);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
public getInstalledInfo(id: string) {
|
||||
@ -60,4 +58,25 @@ export class AppDataService {
|
||||
supported_architectures,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getAppBackups(params: { appId: string; pageSize: number; page: number }) {
|
||||
const { appId, page, pageSize } = params;
|
||||
const backups = await this.dataAccessApp.listBackupsByAppId(appId);
|
||||
backups.reverse();
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const data = backups.slice(start, end);
|
||||
|
||||
return {
|
||||
data,
|
||||
total: backups.length,
|
||||
currentPage: Math.floor(start / pageSize) + 1,
|
||||
lastPage: Math.ceil(backups.length / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteAppBackup(appId: string, filename: string) {
|
||||
return this.dataAccessApp.deleteBackup(appId, filename);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ const appEventSchema = z.object({
|
||||
z.literal('reset'),
|
||||
z.literal('restart'),
|
||||
z.literal('generate_env'),
|
||||
z.literal('backup'),
|
||||
]),
|
||||
appid: z.string(),
|
||||
skipEnv: z.boolean().optional().default(false),
|
||||
@ -34,6 +35,13 @@ const appEventSchema = z.object({
|
||||
.catchall(z.unknown()),
|
||||
});
|
||||
|
||||
const restoreAppCommandSchema = z.object({
|
||||
type: z.literal(EVENT_TYPES.APP),
|
||||
command: z.literal('restore'),
|
||||
appid: z.string(),
|
||||
filename: z.string(),
|
||||
});
|
||||
|
||||
export type AppEventFormInput = z.input<typeof appEventSchema>['form'];
|
||||
export type AppEventForm = z.output<typeof appEventSchema>['form'];
|
||||
|
||||
@ -48,7 +56,7 @@ const systemCommandSchema = z.object({
|
||||
command: z.literal('system_info'),
|
||||
});
|
||||
|
||||
export const eventSchema = appEventSchema.or(repoCommandSchema).or(systemCommandSchema);
|
||||
export const eventSchema = appEventSchema.or(restoreAppCommandSchema).or(repoCommandSchema).or(systemCommandSchema);
|
||||
|
||||
export const eventResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
|
@ -21,6 +21,10 @@ export const socketEventSchema = z.union([
|
||||
z.literal('restart_error'),
|
||||
z.literal('generate_env_success'),
|
||||
z.literal('generate_env_error'),
|
||||
z.literal('backup_success'),
|
||||
z.literal('backup_error'),
|
||||
z.literal('restore_success'),
|
||||
z.literal('restore_error'),
|
||||
]),
|
||||
data: z.object({
|
||||
appId: z.string(),
|
||||
@ -36,6 +40,8 @@ export const socketEventSchema = z.union([
|
||||
'uninstalling',
|
||||
'resetting',
|
||||
'restarting',
|
||||
'backing_up',
|
||||
'restoring',
|
||||
])
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
|
31
packages/worker/assets/migrations/00015-backingup-state.sql
Normal file
31
packages/worker/assets/migrations/00015-backingup-state.sql
Normal file
@ -0,0 +1,31 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
pg_enum
|
||||
WHERE
|
||||
enumlabel = 'backing_up'::text
|
||||
AND enumtypid = 'public.app_status_enum'::regtype) THEN
|
||||
ALTER TYPE "public"."app_status_enum"
|
||||
ADD VALUE 'backing_up';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
pg_enum
|
||||
WHERE
|
||||
enumlabel = 'restoring'::text
|
||||
AND enumtypid = 'public.app_status_enum'::regtype) THEN
|
||||
ALTER TYPE "public"."app_status_enum"
|
||||
ADD VALUE 'restoring';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
@ -19,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@sentry/esbuild-plugin": "^2.21.1",
|
||||
"@types/tar-stream": "^3.1.3",
|
||||
"@types/web-push": "^3.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
@ -48,6 +49,7 @@
|
||||
"pg": "^8.12.0",
|
||||
"socket.io": "^4.7.5",
|
||||
"systeminformation": "^5.22.11",
|
||||
"tar-stream": "^3.1.7",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.4.5",
|
||||
"zod": "^3.23.8"
|
||||
|
103
packages/worker/src/lib/archive/ArchiveManager.ts
Normal file
103
packages/worker/src/lib/archive/ArchiveManager.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { pack, extract, Pack } from 'tar-stream';
|
||||
|
||||
import { Writable } from 'stream';
|
||||
|
||||
export class ArchiveManager {
|
||||
private async addFilesToTar(pack: Pack, dir: string, parent = '') {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
const tarPath = path.join(parent, file);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await this.addFilesToTar(pack, filePath, tarPath);
|
||||
} else {
|
||||
const fileContent = await fs.promises.readFile(filePath);
|
||||
pack.entry({ name: tarPath }, fileContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createTarGz = async (sourceDir: string, destinationFile: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const myPack = pack(); // Create a tar pack stream
|
||||
const gzip = zlib.createGzip(); // Create a gzip stream
|
||||
|
||||
this.addFilesToTar(myPack, sourceDir)
|
||||
.then(() => {
|
||||
myPack.finalize();
|
||||
|
||||
// Collect the compressed tarball in memory
|
||||
const chunks: Buffer[] = [];
|
||||
const writable = new Writable({
|
||||
write(chunk, _, callback) {
|
||||
chunks.push(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
writable.on('finish', async () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
await fs.promises.writeFile(destinationFile, buffer);
|
||||
resolve();
|
||||
});
|
||||
|
||||
writable.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
myPack.pipe(gzip).pipe(writable);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
extractTarGz = async (sourceFile: string, destinationDir: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
fs.promises
|
||||
.readFile(sourceFile)
|
||||
.then((buffer) => {
|
||||
const extractor = extract();
|
||||
const gunzip = zlib.createGunzip();
|
||||
|
||||
extractor.on('entry', async (header, stream, next) => {
|
||||
const filePath = path.join(destinationDir, header.name);
|
||||
|
||||
if (header.type === 'directory') {
|
||||
await fs.promises.mkdir(filePath, { recursive: true });
|
||||
next();
|
||||
} else {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on('end', async () => {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(filePath, Buffer.concat(chunks));
|
||||
next();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
extractor.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
extractor.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
gunzip.pipe(extractor);
|
||||
gunzip.write(buffer);
|
||||
gunzip.end();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
}
|
@ -238,6 +238,7 @@ export const copySystemFiles = async (envMap: Map<EnvKeys, string>) => {
|
||||
await fs.promises.mkdir(APP_DATA_DIR, { recursive: true });
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'state'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'repos'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'backups'), { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error("Couldn't create base folders", error);
|
||||
}
|
||||
|
@ -2,12 +2,15 @@ import fs from 'fs';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { pathExists } from '@runtipi/shared/node';
|
||||
import * as sharedNode from '@runtipi/shared/node';
|
||||
import { AppExecutors } from '../app.executors';
|
||||
import { createAppConfig } from '@/tests/apps.factory';
|
||||
import * as dockerHelpers from '@/lib/docker';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { ArchiveManager } from '@/lib/archive/ArchiveManager';
|
||||
|
||||
const { pathExists } = sharedNode;
|
||||
|
||||
const { appsRepoId } = getEnv();
|
||||
|
||||
@ -17,7 +20,9 @@ describe('test: app executors', () => {
|
||||
describe('test: installApp()', () => {
|
||||
it('should run correct compose script', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
|
||||
const config = createAppConfig({}, false);
|
||||
|
||||
// act
|
||||
@ -28,7 +33,10 @@ describe('test: app executors', () => {
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(message).toBe(`App ${config.id} installed successfully`);
|
||||
expect(spy).toHaveBeenCalledWith(config.id, 'up --detach --force-recreate --remove-orphans --pull always');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
config.id,
|
||||
'up --detach --force-recreate --remove-orphans --pull always',
|
||||
);
|
||||
expect(envExists).toBe(true);
|
||||
spy.mockRestore();
|
||||
});
|
||||
@ -84,15 +92,23 @@ describe('test: app executors', () => {
|
||||
// arrange
|
||||
const config = createAppConfig({}, false);
|
||||
const filename = faker.system.fileName();
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test');
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename),
|
||||
'test',
|
||||
);
|
||||
|
||||
// act
|
||||
await appExecutors.installApp(config.id, config);
|
||||
|
||||
// assert
|
||||
const exists = await pathExists(path.join(APP_DATA_DIR, config.id, 'data', filename));
|
||||
const data = await fs.promises.readFile(path.join(APP_DATA_DIR, config.id, 'data', filename), 'utf-8');
|
||||
const data = await fs.promises.readFile(
|
||||
path.join(APP_DATA_DIR, config.id, 'data', filename),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(exists).toBe(true);
|
||||
expect(data).toBe('test');
|
||||
@ -103,15 +119,23 @@ describe('test: app executors', () => {
|
||||
const config = createAppConfig();
|
||||
const filename = faker.system.fileName();
|
||||
await fs.promises.writeFile(path.join(APP_DATA_DIR, config.id, 'data', filename), 'test');
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah');
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename),
|
||||
'yeah',
|
||||
);
|
||||
|
||||
// act
|
||||
await appExecutors.installApp(config.id, config);
|
||||
|
||||
// assert
|
||||
const exists = await pathExists(path.join(APP_DATA_DIR, config.id, 'data', filename));
|
||||
const data = await fs.promises.readFile(path.join(APP_DATA_DIR, config.id, 'data', filename), 'utf-8');
|
||||
const data = await fs.promises.readFile(
|
||||
path.join(APP_DATA_DIR, config.id, 'data', filename),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(exists).toBe(true);
|
||||
expect(data).toBe('test');
|
||||
@ -119,7 +143,9 @@ describe('test: app executors', () => {
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test')));
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockImplementation(() => Promise.reject(new Error('test')));
|
||||
const config = createAppConfig();
|
||||
|
||||
// act
|
||||
@ -144,7 +170,9 @@ describe('test: app executors', () => {
|
||||
describe('test: stopApp()', () => {
|
||||
it('should run correct compose script', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
|
||||
const config = createAppConfig();
|
||||
|
||||
// act
|
||||
@ -159,7 +187,9 @@ describe('test: app executors', () => {
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test')));
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockImplementation(() => Promise.reject(new Error('test')));
|
||||
const config = createAppConfig();
|
||||
|
||||
// act
|
||||
@ -175,7 +205,9 @@ describe('test: app executors', () => {
|
||||
describe('test: restartApp()', () => {
|
||||
it('should start and stop the app', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
|
||||
const config = createAppConfig();
|
||||
|
||||
// act
|
||||
@ -184,14 +216,19 @@ describe('test: app executors', () => {
|
||||
// assert
|
||||
expect(success).toBe(true);
|
||||
expect(message).toBe(`App ${config.id} restarted successfully`);
|
||||
expect(spy).toHaveBeenCalledWith(config.id, 'up --detach --force-recreate --remove-orphans --pull always');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
config.id,
|
||||
'up --detach --force-recreate --remove-orphans --pull always',
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(config.id, 'rm --force --stop');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test')));
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockImplementation(() => Promise.reject(new Error('test')));
|
||||
const config = createAppConfig();
|
||||
|
||||
// act
|
||||
@ -210,8 +247,11 @@ describe('test: app executors', () => {
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose');
|
||||
const config = createAppConfig();
|
||||
|
||||
spy.mockRejectedValueOnce(new Error('test'));
|
||||
spy.mockResolvedValueOnce({ stdout: 'done', stderr: '' });
|
||||
spy.mockImplementationOnce(() => {
|
||||
throw new Error('test');
|
||||
});
|
||||
spy.mockResolvedValue({ stdout: 'done', stderr: '' });
|
||||
|
||||
// act
|
||||
const { message, success } = await appExecutors.updateApp(config.id, config);
|
||||
@ -224,6 +264,9 @@ describe('test: app executors', () => {
|
||||
|
||||
it('should replace app directory with new one', async () => {
|
||||
// arrange
|
||||
const spy = vi.spyOn(dockerHelpers, 'compose');
|
||||
spy.mockResolvedValue({ stdout: 'done', stderr: '' });
|
||||
|
||||
const config = createAppConfig();
|
||||
const oldFolder = path.join(DATA_DIR, 'apps', config.id);
|
||||
|
||||
@ -234,10 +277,70 @@ describe('test: app executors', () => {
|
||||
|
||||
// assert
|
||||
const exists = await pathExists(oldFolder);
|
||||
const content = await fs.promises.readFile(path.join(oldFolder, 'docker-compose.yml'), 'utf-8');
|
||||
const content = await fs.promises.readFile(
|
||||
path.join(oldFolder, 'docker-compose.yml'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(exists).toBe(true);
|
||||
expect(content).not.toBe('test');
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('test: backupApp()', () => {
|
||||
it('should backup folders to /temp/<app-id>/<app-id>-timestamp.tar.gz', async () => {
|
||||
// arrange
|
||||
const config = createAppConfig({}, true);
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockResolvedValue({ stdout: 'done', stderr: '' });
|
||||
|
||||
// act
|
||||
await appExecutors.backupApp(config.id);
|
||||
const { success } = await appExecutors.backupApp(config.id);
|
||||
|
||||
// assert
|
||||
expect(success).toBe(true);
|
||||
const backups = await fs.promises.readdir(path.join(DATA_DIR, 'backups', config.id));
|
||||
|
||||
expect(backups.length).toBe(2);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('test: restoreApp()', () => {
|
||||
it('should restore app from backup', async () => {
|
||||
// arrange
|
||||
const spy = vi
|
||||
.spyOn(dockerHelpers, 'compose')
|
||||
.mockResolvedValue({ stdout: 'done', stderr: '' });
|
||||
const config = createAppConfig({ version: '2.0.0' }, true);
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'backups', config.id), { recursive: true });
|
||||
const filename = path.join(DATA_DIR, 'backups', config.id, 'test.tar.gz');
|
||||
const tempDir = path.join('/tmp', config.id);
|
||||
await fs.promises.mkdir(tempDir, { recursive: true });
|
||||
await fs.promises.cp(path.join(APP_DATA_DIR, config.id), path.join(tempDir, 'app-data'));
|
||||
await fs.promises.cp(path.join(DATA_DIR, 'apps', config.id), path.join(tempDir, 'app'));
|
||||
|
||||
// Create tar.gz file
|
||||
const archiveManager = new ArchiveManager();
|
||||
await archiveManager.createTarGz(tempDir, filename);
|
||||
await fs.promises.rm(tempDir, { recursive: true });
|
||||
// Set different version
|
||||
const fileToCheck = path.join(DATA_DIR, 'apps', config.id, 'config.json');
|
||||
await fs.promises.writeFile(fileToCheck, JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
// act
|
||||
const { success } = await appExecutors.restoreApp(config.id, 'test.tar.gz');
|
||||
|
||||
// assert
|
||||
expect(success).toBe(true);
|
||||
const content = await fs.promises.readFile(fileToCheck, 'utf-8');
|
||||
expect(JSON.parse(content).version).toBe('2.0.0');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { execAsync, pathExists } from '@runtipi/shared/node';
|
||||
import { AppEventForm, SocketEvent, sanitizePath } from '@runtipi/shared';
|
||||
import { AppEventForm, SocketEvent, appInfoSchema, sanitizePath } from '@runtipi/shared';
|
||||
import { copyDataDir, generateEnvFile } from './app.helpers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { compose } from '@/lib/docker';
|
||||
@ -13,12 +13,15 @@ import { SocketManager } from '@/lib/socket/SocketManager';
|
||||
import { getDbClient } from '@/lib/db';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { getDockerCompose } from '@/config/docker-templates';
|
||||
import { ArchiveManager } from '@/lib/archive/ArchiveManager';
|
||||
|
||||
export class AppExecutors {
|
||||
private readonly logger;
|
||||
private archiveManager: ArchiveManager;
|
||||
|
||||
constructor() {
|
||||
this.logger = logger;
|
||||
this.archiveManager = new ArchiveManager();
|
||||
}
|
||||
|
||||
private handleAppError = async (
|
||||
@ -244,7 +247,6 @@ export class AppExecutors {
|
||||
await client?.query('UPDATE app SET status = $1 WHERE id = $2', ['stopped', appId]);
|
||||
return { success: true, message: `App ${appId} stopped successfully` };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return this.handleAppError(err, appId, 'stop_error', 'running');
|
||||
}
|
||||
};
|
||||
@ -454,6 +456,9 @@ export class AppExecutors {
|
||||
|
||||
public updateApp = async (appId: string, form: AppEventForm) => {
|
||||
try {
|
||||
// Creating backup of the app before updating
|
||||
await this.backupApp(appId);
|
||||
|
||||
await SocketManager.emit({
|
||||
type: 'app',
|
||||
event: 'status_change',
|
||||
@ -536,4 +541,158 @@ export class AppExecutors {
|
||||
this.logger.error(`Error starting apps: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
public backupApp = async (appId: string) => {
|
||||
try {
|
||||
await SocketManager.emit({
|
||||
type: 'app',
|
||||
event: 'status_change',
|
||||
data: { appId, appStatus: 'backing_up' },
|
||||
});
|
||||
|
||||
const { appDataDirPath, appDirPath } = this.getAppPaths(appId);
|
||||
const backupName = `${appId}-${new Date().getTime()}`;
|
||||
const backupDir = path.join(DATA_DIR, 'backups', appId);
|
||||
const tempDir = path.join('/tmp', appId);
|
||||
|
||||
// Stop app so containers like databases don't cause problems
|
||||
this.logger.info(`Stopping app ${appId}`);
|
||||
|
||||
await compose(appId, 'rm --force --stop');
|
||||
|
||||
this.logger.info('App stopped!');
|
||||
|
||||
this.logger.info('Copying files to backup location...');
|
||||
|
||||
// Ensure backup directory exists
|
||||
await fs.promises.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Move app data and app directories
|
||||
await fs.promises.cp(appDataDirPath, path.join(tempDir, 'app-data'), {
|
||||
recursive: true,
|
||||
filter: (src) => !src.includes('backups'),
|
||||
});
|
||||
await fs.promises.cp(appDirPath, path.join(tempDir, 'app'), { recursive: true });
|
||||
|
||||
// Check if the user config folder exists and if it does copy it too
|
||||
if (await pathExists(path.join(DATA_DIR, 'user-config', appId))) {
|
||||
await fs.promises.cp(
|
||||
path.join(DATA_DIR, 'user-config', appId),
|
||||
path.join(tempDir, 'user-config'),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info('Creating archive...');
|
||||
|
||||
// Create the archive
|
||||
await this.archiveManager.createTarGz(tempDir, `${path.join(tempDir, backupName)}.tar.gz`);
|
||||
|
||||
this.logger.info('Moving archive to backup directory...');
|
||||
|
||||
// Move the archive to the backup directory
|
||||
await fs.promises.mkdir(backupDir, { recursive: true });
|
||||
await fs.promises.cp(
|
||||
path.join(tempDir, `${backupName}.tar.gz`),
|
||||
path.join(backupDir, `${backupName}.tar.gz`),
|
||||
);
|
||||
|
||||
// Remove the temp backup folder
|
||||
await fs.promises.rm(tempDir, { force: true, recursive: true });
|
||||
|
||||
this.logger.info('Backup completed!');
|
||||
|
||||
// Done
|
||||
await SocketManager.emit({
|
||||
type: 'app',
|
||||
event: 'backup_success',
|
||||
data: { appId, appStatus: 'stopped' },
|
||||
});
|
||||
return { success: true, message: `App ${appId} backed up successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'backup_error');
|
||||
}
|
||||
};
|
||||
|
||||
public restoreApp = async (appId: string, filename: string) => {
|
||||
try {
|
||||
await SocketManager.emit({
|
||||
type: 'app',
|
||||
event: 'status_change',
|
||||
data: { appId, appStatus: 'restoring' },
|
||||
});
|
||||
|
||||
const { appDataDirPath, appDirPath } = this.getAppPaths(appId);
|
||||
const restoreDir = path.join('/tmp', appId);
|
||||
const archive = path.join(DATA_DIR, 'backups', appId, filename);
|
||||
const client = await getDbClient();
|
||||
|
||||
this.logger.info('Restoring app from backup...');
|
||||
|
||||
// Verify the app has a backup
|
||||
if (!(await pathExists(archive))) {
|
||||
throw new Error('The backup file does not exist');
|
||||
}
|
||||
|
||||
// Stop the app
|
||||
this.logger.info(`Stopping app ${appId}`);
|
||||
|
||||
await compose(appId, 'rm --force --stop');
|
||||
|
||||
this.logger.info('App stopped!');
|
||||
|
||||
// Unzip the archive
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await this.archiveManager.extractTarGz(archive, restoreDir);
|
||||
|
||||
// Remove old data directories
|
||||
await fs.promises.rm(appDataDirPath, { force: true, recursive: true });
|
||||
await fs.promises.rm(appDirPath, { force: true, recursive: true });
|
||||
await fs.promises.rm(path.join(DATA_DIR, 'user-config', appId), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(appDataDirPath, { recursive: true });
|
||||
await fs.promises.mkdir(appDirPath, { recursive: true });
|
||||
|
||||
// Copy data from the backup folder
|
||||
await fs.promises.cp(path.join(restoreDir, 'app'), appDirPath, { recursive: true });
|
||||
await fs.promises.cp(path.join(restoreDir, 'app-data'), appDataDirPath, { recursive: true });
|
||||
|
||||
// Copy user config foler if it exists
|
||||
if (await pathExists(path.join(restoreDir, 'user-config'))) {
|
||||
await fs.promises.cp(
|
||||
path.join(restoreDir, 'user-config'),
|
||||
path.join(DATA_DIR, 'user-config', appId),
|
||||
{ recursive: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete restore folder
|
||||
await fs.promises.rm(restoreDir, { force: true, recursive: true });
|
||||
|
||||
// Set the version in the database
|
||||
const configFileRaw = await fs.promises.readFile(path.join(appDirPath, 'config.json'), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const configParsed = appInfoSchema.safeParse(JSON.parse(configFileRaw));
|
||||
await client?.query(`UPDATE app SET version = $1 WHERE id = $2`, [
|
||||
configParsed.data?.tipi_version,
|
||||
appId,
|
||||
]);
|
||||
|
||||
this.logger.info(`App ${appId} restored!`);
|
||||
|
||||
// Done
|
||||
await SocketManager.emit({
|
||||
type: 'app',
|
||||
event: 'restore_success',
|
||||
data: { appId, appStatus: 'stopped' },
|
||||
});
|
||||
|
||||
return { success: true, message: `App ${appId} restored successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'restore_error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -13,6 +13,8 @@ const {
|
||||
uninstallApp,
|
||||
updateApp,
|
||||
regenerateAppEnv,
|
||||
backupApp,
|
||||
restoreApp,
|
||||
} = new AppExecutors();
|
||||
const { cloneRepo, pullRepo } = new RepoExecutors();
|
||||
|
||||
@ -60,6 +62,14 @@ const runCommand = async (jobData: unknown) => {
|
||||
if (data.command === 'generate_env') {
|
||||
({ success, message } = await regenerateAppEnv(data.appid, data.form));
|
||||
}
|
||||
|
||||
if (data.command === 'backup') {
|
||||
({ success, message } = await backupApp(data.appid));
|
||||
}
|
||||
|
||||
if (data.command === 'restore') {
|
||||
({ success, message } = await restoreApp(data.appid, data.filename));
|
||||
}
|
||||
} else if (data.type === 'repo') {
|
||||
if (data.command === 'clone') {
|
||||
({ success, message } = await cloneRepo(data.url));
|
||||
|
@ -35,6 +35,7 @@ beforeEach(async () => {
|
||||
|
||||
await fs.promises.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'state'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'backups'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(DATA_DIR, 'state', 'seed'), 'seed');
|
||||
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', 'repo-id', 'apps'), { recursive: true });
|
||||
});
|
||||
|
187
pnpm-lock.yaml
187
pnpm-lock.yaml
@ -59,6 +59,9 @@ importers:
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.11.0
|
||||
version: 3.11.0(react@18.3.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.51.1
|
||||
version: 5.51.1(react@18.3.1)
|
||||
argon2:
|
||||
specifier: ^0.40.3
|
||||
version: 0.40.3
|
||||
@ -128,9 +131,6 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1(@types/react@18.3.3)(react@18.3.1)
|
||||
react-query:
|
||||
specifier: ^3.39.3
|
||||
version: 3.39.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-timezone-select:
|
||||
specifier: ^3.2.5
|
||||
version: 3.2.5(react-dom@18.3.1(react@18.3.1))(react-select@5.8.0(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
@ -411,6 +411,9 @@ importers:
|
||||
systeminformation:
|
||||
specifier: ^5.22.11
|
||||
version: 5.22.11
|
||||
tar-stream:
|
||||
specifier: ^3.1.7
|
||||
version: 3.1.7
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
@ -427,6 +430,9 @@ importers:
|
||||
'@sentry/esbuild-plugin':
|
||||
specifier: ^2.21.1
|
||||
version: 2.21.1
|
||||
'@types/tar-stream':
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.3
|
||||
version: 3.6.3
|
||||
@ -2521,6 +2527,14 @@ packages:
|
||||
'@tabler/icons@3.11.0':
|
||||
resolution: {integrity: sha512-/vZinJNvCYhdAB+RUsyCpanSPuOEKHHIZi4Uu0Bw7ilewHnQhCWUPrT704uHCRli2ROl7spADPmWzAqOganA5A==}
|
||||
|
||||
'@tanstack/query-core@5.51.1':
|
||||
resolution: {integrity: sha512-fJBMQMpo8/KSsWW5ratJR5+IFr7YNJ3K2kfP9l5XObYHsgfVy1w3FJUWU4FT2fj7+JMaEg33zOcNDBo0LMwHnw==}
|
||||
|
||||
'@tanstack/react-query@5.51.1':
|
||||
resolution: {integrity: sha512-s47HKFnQ4HOJAHoIiXcpna/roMMPZJPy6fJ6p4ZNVn8+/onlLBEDd1+xc8OnDuwgvecqkZD7Z2mnSRbcWefrKw==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
|
||||
'@testing-library/dom@10.3.2':
|
||||
resolution: {integrity: sha512-0bxIdP9mmPiOJ6wHLj8bdJRq+51oddObeCGdEf6PNEhYd93ZYAN+lPRnEOVFtheVwDM7+p+tza3LAQgp0PTudg==}
|
||||
engines: {node: '>=18'}
|
||||
@ -2705,6 +2719,9 @@ packages:
|
||||
'@types/statuses@2.0.5':
|
||||
resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==}
|
||||
|
||||
'@types/tar-stream@3.1.3':
|
||||
resolution: {integrity: sha512-Zbnx4wpkWBMBSu5CytMbrT5ZpMiF55qgM+EpHzR4yIDu7mv52cej8hTkOc6K+LzpkOAbxwn/m7j3iO+/l42YkQ==}
|
||||
|
||||
'@types/triple-beam@1.3.5':
|
||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||
|
||||
@ -3135,6 +3152,9 @@ packages:
|
||||
axobject-query@3.1.1:
|
||||
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
|
||||
|
||||
b4a@1.6.6:
|
||||
resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==}
|
||||
|
||||
babel-jest@29.7.0:
|
||||
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@ -3170,14 +3190,13 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
bare-events@2.4.2:
|
||||
resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==}
|
||||
|
||||
base64id@2.0.0:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -3200,11 +3219,8 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
broadcast-channel@3.7.0:
|
||||
resolution: {integrity: sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==}
|
||||
|
||||
browserslist@4.23.2:
|
||||
resolution: {integrity: sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==}
|
||||
browserslist@4.23.1:
|
||||
resolution: {integrity: sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
@ -3553,9 +3569,6 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
detect-node@2.1.0:
|
||||
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
@ -4000,6 +4013,9 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-glob@3.3.2:
|
||||
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@ -4719,9 +4735,6 @@ packages:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
js-sha3@0.8.0:
|
||||
resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@ -4943,9 +4956,6 @@ packages:
|
||||
markdown-table@3.0.3:
|
||||
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
|
||||
|
||||
match-sorter@6.3.4:
|
||||
resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==}
|
||||
|
||||
mdast-util-find-and-replace@3.0.1:
|
||||
resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==}
|
||||
|
||||
@ -5096,9 +5106,6 @@ packages:
|
||||
resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
microseconds@0.2.0:
|
||||
resolution: {integrity: sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -5188,9 +5195,6 @@ packages:
|
||||
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
nano-time@1.0.0:
|
||||
resolution: {integrity: sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==}
|
||||
|
||||
nanoid@3.3.7:
|
||||
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@ -5337,9 +5341,6 @@ packages:
|
||||
resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
oblivious-set@1.0.0:
|
||||
resolution: {integrity: sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==}
|
||||
|
||||
obuf@1.1.2:
|
||||
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
||||
|
||||
@ -5638,6 +5639,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
queue-tick@1.0.1:
|
||||
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@ -5674,18 +5678,6 @@ packages:
|
||||
'@types/react': '>=18'
|
||||
react: '>=18'
|
||||
|
||||
react-query@3.39.3:
|
||||
resolution: {integrity: sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.14.2:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -5804,9 +5796,6 @@ packages:
|
||||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -6064,6 +6053,9 @@ packages:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
streamx@2.18.0:
|
||||
resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
|
||||
|
||||
@ -6200,6 +6192,9 @@ packages:
|
||||
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@3.1.7:
|
||||
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
|
||||
|
||||
terser-webpack-plugin@5.3.10:
|
||||
resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
@ -6225,6 +6220,9 @@ packages:
|
||||
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
text-decoder@1.1.1:
|
||||
resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==}
|
||||
|
||||
text-hex@1.0.0:
|
||||
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
|
||||
|
||||
@ -6418,9 +6416,6 @@ packages:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
unload@2.2.0:
|
||||
resolution: {integrity: sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==}
|
||||
|
||||
unplugin@1.0.1:
|
||||
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
|
||||
|
||||
@ -6873,7 +6868,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.24.9
|
||||
'@babel/helper-validator-option': 7.24.8
|
||||
browserslist: 4.23.2
|
||||
browserslist: 4.23.1
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
@ -8839,6 +8834,13 @@ snapshots:
|
||||
|
||||
'@tabler/icons@3.11.0': {}
|
||||
|
||||
'@tanstack/query-core@5.51.1': {}
|
||||
|
||||
'@tanstack/react-query@5.51.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.51.1
|
||||
react: 18.3.1
|
||||
|
||||
'@testing-library/dom@10.3.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.24.7
|
||||
@ -9042,6 +9044,10 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.5': {}
|
||||
|
||||
'@types/tar-stream@3.1.3':
|
||||
dependencies:
|
||||
'@types/node': 20.14.11
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
'@types/unist@2.0.10': {}
|
||||
@ -9618,6 +9624,8 @@ snapshots:
|
||||
dependencies:
|
||||
deep-equal: 2.2.3
|
||||
|
||||
b4a@1.6.6: {}
|
||||
|
||||
babel-jest@29.7.0(@babel/core@7.24.9):
|
||||
dependencies:
|
||||
'@babel/core': 7.24.9
|
||||
@ -9680,9 +9688,10 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64id@2.0.0: {}
|
||||
bare-events@2.4.2:
|
||||
optional: true
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
base64id@2.0.0: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
@ -9705,23 +9714,12 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
broadcast-channel@3.7.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
detect-node: 2.1.0
|
||||
js-sha3: 0.8.0
|
||||
microseconds: 0.2.0
|
||||
nano-time: 1.0.0
|
||||
oblivious-set: 1.0.0
|
||||
rimraf: 3.0.2
|
||||
unload: 2.2.0
|
||||
|
||||
browserslist@4.23.2:
|
||||
browserslist@4.23.1:
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001642
|
||||
electron-to-chromium: 1.4.830
|
||||
node-releases: 2.0.17
|
||||
update-browserslist-db: 1.1.0(browserslist@4.23.2)
|
||||
update-browserslist-db: 1.1.0(browserslist@4.23.1)
|
||||
|
||||
bser@2.1.1:
|
||||
dependencies:
|
||||
@ -10069,8 +10067,6 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
detect-node@2.1.0: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@ -10730,6 +10726,8 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-glob@3.3.2:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@ -11699,8 +11697,6 @@ snapshots:
|
||||
|
||||
js-cookie@3.0.5: {}
|
||||
|
||||
js-sha3@0.8.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.0: {}
|
||||
@ -11959,11 +11955,6 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.3: {}
|
||||
|
||||
match-sorter@6.3.4:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
remove-accents: 0.5.0
|
||||
|
||||
mdast-util-find-and-replace@3.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@ -12331,8 +12322,6 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
microseconds@0.2.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
@ -12426,10 +12415,6 @@ snapshots:
|
||||
|
||||
mute-stream@1.0.0: {}
|
||||
|
||||
nano-time@1.0.0:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
|
||||
nanoid@3.3.7: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@ -12579,8 +12564,6 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.0.0
|
||||
|
||||
oblivious-set@1.0.0: {}
|
||||
|
||||
obuf@1.1.2: {}
|
||||
|
||||
once@1.4.0:
|
||||
@ -12860,6 +12843,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
queue-tick@1.0.1: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@ -12905,15 +12890,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-query@3.39.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
broadcast-channel: 3.7.0
|
||||
match-sorter: 6.3.4
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.6(@types/react@18.3.3)(react@18.3.1):
|
||||
@ -13086,8 +13062,6 @@ snapshots:
|
||||
mdast-util-to-markdown: 2.1.0
|
||||
unified: 11.0.4
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-in-the-middle@7.3.0:
|
||||
@ -13387,6 +13361,14 @@ snapshots:
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
streamx@2.18.0:
|
||||
dependencies:
|
||||
fast-fifo: 1.3.2
|
||||
queue-tick: 1.0.1
|
||||
text-decoder: 1.1.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.4.2
|
||||
|
||||
strict-event-emitter@0.5.1: {}
|
||||
|
||||
string-length@4.0.2:
|
||||
@ -13527,6 +13509,12 @@ snapshots:
|
||||
|
||||
tapable@2.2.1: {}
|
||||
|
||||
tar-stream@3.1.7:
|
||||
dependencies:
|
||||
b4a: 1.6.6
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.18.0
|
||||
|
||||
terser-webpack-plugin@5.3.10(webpack@5.92.1):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
@ -13549,6 +13537,10 @@ snapshots:
|
||||
glob: 7.2.3
|
||||
minimatch: 3.1.2
|
||||
|
||||
text-decoder@1.1.1:
|
||||
dependencies:
|
||||
b4a: 1.6.6
|
||||
|
||||
text-hex@1.0.0: {}
|
||||
|
||||
text-table@0.2.0: {}
|
||||
@ -13729,11 +13721,6 @@ snapshots:
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
unload@2.2.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
detect-node: 2.1.0
|
||||
|
||||
unplugin@1.0.1:
|
||||
dependencies:
|
||||
acorn: 8.12.1
|
||||
@ -13741,9 +13728,9 @@ snapshots:
|
||||
webpack-sources: 3.2.3
|
||||
webpack-virtual-modules: 0.5.0
|
||||
|
||||
update-browserslist-db@1.1.0(browserslist@4.23.2):
|
||||
update-browserslist-db@1.1.0(browserslist@4.23.1):
|
||||
dependencies:
|
||||
browserslist: 4.23.2
|
||||
browserslist: 4.23.1
|
||||
escalade: 3.1.2
|
||||
picocolors: 1.0.1
|
||||
|
||||
@ -13953,7 +13940,7 @@ snapshots:
|
||||
'@webassemblyjs/wasm-parser': 1.12.1
|
||||
acorn: 8.12.1
|
||||
acorn-import-attributes: 1.9.5(acorn@8.12.1)
|
||||
browserslist: 4.23.2
|
||||
browserslist: 4.23.1
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.17.0
|
||||
es-module-lexer: 1.5.4
|
||||
|
@ -8,7 +8,7 @@
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.3.1'
|
||||
const PACKAGE_VERSION = '2.3.2'
|
||||
const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
@ -188,6 +188,12 @@ export const AppActions: React.FC<IProps> = ({ app, localDomain }) => {
|
||||
case 'resetting':
|
||||
buttons.push(LoadingButton, CancelButton);
|
||||
break;
|
||||
case 'backing_up':
|
||||
buttons.push(LoadingButton, CancelButton);
|
||||
break;
|
||||
case 'restoring':
|
||||
buttons.push(LoadingButton, CancelButton);
|
||||
break;
|
||||
case 'missing':
|
||||
buttons.push(InstallButton);
|
||||
break;
|
||||
|
@ -5,13 +5,15 @@ import { AppStatus } from '@/components/AppStatus';
|
||||
import { AppActions } from '../AppActions';
|
||||
import { AppDetailsTabs } from '../AppDetailsTabs';
|
||||
import { GetAppCommand } from '@/server/services/app-catalog/commands';
|
||||
import { AppBackupsApiResponse } from '@/api/app-backups/route';
|
||||
|
||||
type AppDetailsContainerProps = {
|
||||
app: Awaited<ReturnType<GetAppCommand['execute']>>;
|
||||
backups: AppBackupsApiResponse;
|
||||
localDomain?: string;
|
||||
};
|
||||
|
||||
export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, localDomain }) => {
|
||||
export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, localDomain, backups }) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@ -30,7 +32,7 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
|
||||
<AppActions localDomain={localDomain} app={app} />
|
||||
</div>
|
||||
</div>
|
||||
<AppDetailsTabs info={app.info} />
|
||||
<AppDetailsTabs info={app.info} backups={backups} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,188 @@
|
||||
import type { AppBackup, AppBackupsApiResponse } from '@/api/app-backups/route';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/Table';
|
||||
import React from 'react';
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TablePagination } from 'src/app/components/TablePagination/TablePagination';
|
||||
import { BackupModal } from '../BackupModal';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { useDisclosure } from '@/client/hooks/useDisclosure';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import toast from 'react-hot-toast';
|
||||
import { RestoreModal } from '../RestoreModal';
|
||||
import { FileSize } from '@/components/FileSize/FileSize';
|
||||
import { useAppStatus } from '@/hooks/useAppStatus';
|
||||
import { DateFormat } from '@/components/DateFormat/DateFormat';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DeleteBackupModal } from '../DeleteBackupModal/DeleteBackupModal';
|
||||
import { createAppBackupAction } from '@/actions/backup/create-app-backup-action';
|
||||
import { restoreAppBackupAction } from '@/actions/backup/restore-app-backup-action';
|
||||
import { deleteAppBackupAction } from '@/actions/backup/delete-app-backup';
|
||||
|
||||
type Props = {
|
||||
info: AppInfo;
|
||||
initialData: AppBackupsApiResponse;
|
||||
};
|
||||
|
||||
async function getBackupsQueryFn(params: { appId: string; page: number; pageSize: number }) {
|
||||
const url = new URL('/api/app-backups', window.location.origin);
|
||||
|
||||
url.searchParams.append('appId', params.appId);
|
||||
|
||||
url.searchParams.append('page', params.page.toString());
|
||||
url.searchParams.append('pageSize', params.pageSize.toString());
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Problem fetching data');
|
||||
}
|
||||
return response.json() as Promise<AppBackupsApiResponse>;
|
||||
}
|
||||
|
||||
export const AppBackups = ({ info, initialData }: Props) => {
|
||||
const t = useTranslations();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [selectedBackup, setSelectedBackup] = React.useState<AppBackup | null>(null);
|
||||
const appStatus = useAppStatus((state) => state.statuses[info.id]) || 'missing';
|
||||
|
||||
const backupModalDisclosure = useDisclosure();
|
||||
const restoreModalDisclosure = useDisclosure();
|
||||
const deleteBackupModalDisclosure = useDisclosure();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['app-backups', page, info.id, initialData.total],
|
||||
queryFn: () => getBackupsQueryFn({ appId: info.id, page: page, pageSize: 5 }),
|
||||
initialData,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const backupMutation = useAction(createAppBackupAction, {
|
||||
onExecute: () => {
|
||||
backupModalDisclosure.close();
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
if (error.serverError) toast.error(error.serverError);
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = useAction(restoreAppBackupAction, {
|
||||
onExecute: () => {
|
||||
restoreModalDisclosure.close();
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
if (error.serverError) toast.error(error.serverError);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useAction(deleteAppBackupAction, {
|
||||
onExecute: () => {
|
||||
deleteBackupModalDisclosure.close();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('BACKUPS_LIST_DELETE_SUCCESS'));
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-backups'] });
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
if (error.serverError) toast.error(error.serverError);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRestoreClick = (backup: AppBackup) => {
|
||||
setSelectedBackup(backup);
|
||||
restoreModalDisclosure.open();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (backup: AppBackup) => {
|
||||
setSelectedBackup(backup);
|
||||
deleteBackupModalDisclosure.open();
|
||||
};
|
||||
|
||||
const disableActions =
|
||||
appStatus === 'missing' ||
|
||||
appStatus === 'backing_up' ||
|
||||
appStatus === 'restoring' ||
|
||||
backupMutation.status === 'executing' ||
|
||||
restoreMutation.status === 'executing';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div className="">
|
||||
<h3 className="h3 mb-0">{t('BACKUPS_LIST')}</h3>
|
||||
</div>
|
||||
<Button onClick={backupModalDisclosure.open} variant="outline" intent="primary" disabled={disableActions}>
|
||||
{t('BACKUPS_LIST_BACKUP_NOW')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('BACKUPS_LIST_ROW_TITLE_ID')}</TableHead>
|
||||
<TableHead>{t('BACKUPS_LIST_ROW_TITLE_SIZE')}</TableHead>
|
||||
<TableHead>{t('BACKUPS_LIST_ROW_TITLE_DATE')}</TableHead>
|
||||
<TableHead>{t('BACKUPS_LIST_ROW_TITLE_ACTIONS')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.data.map((backup) => (
|
||||
<TableRow key={backup.id}>
|
||||
<TableCell>{backup.id}</TableCell>
|
||||
<TableCell>
|
||||
<FileSize size={backup.size} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateFormat date={backup.date} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
intent="primary"
|
||||
variant="ghost"
|
||||
onClick={() => handleRestoreClick(backup)}
|
||||
disabled={disableActions}
|
||||
className="me-1"
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
<Button size="sm" intent="danger" variant="ghost" onClick={() => handleDeleteClick(backup)} disabled={disableActions}>
|
||||
Delete
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="card-footer d-flex justify-content-end">
|
||||
<TablePagination
|
||||
totalPages={Math.max(1, data.lastPage)}
|
||||
currentPage={page}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onBack={() => setPage(page - 1)}
|
||||
onNext={() => setPage(page + 1)}
|
||||
/>
|
||||
</div>
|
||||
<BackupModal
|
||||
info={info}
|
||||
isOpen={backupModalDisclosure.isOpen}
|
||||
onClose={backupModalDisclosure.close}
|
||||
onConfirm={() => backupMutation.execute({ id: info.id })}
|
||||
/>
|
||||
<RestoreModal
|
||||
appName={info.name}
|
||||
backup={selectedBackup}
|
||||
isOpen={restoreModalDisclosure.isOpen}
|
||||
onClose={restoreModalDisclosure.close}
|
||||
onConfirm={() => selectedBackup && restoreMutation.execute({ id: info.id, filename: selectedBackup.id })}
|
||||
/>
|
||||
<DeleteBackupModal
|
||||
backup={selectedBackup}
|
||||
isOpen={deleteBackupModalDisclosure.isOpen}
|
||||
onClose={deleteBackupModalDisclosure.close}
|
||||
onConfirm={() => selectedBackup && deleteMutation.execute({ appId: info.id, filename: selectedBackup.id })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,16 +5,19 @@ import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Markdown } from '@/components/Markdown';
|
||||
import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
|
||||
import { AppLogs } from './AppLogs';
|
||||
import { useAppStatus } from '@/hooks/useAppStatus';
|
||||
import { AppBackups } from './AppBackups';
|
||||
import type { AppBackupsApiResponse } from '@/api/app-backups/route';
|
||||
import { Markdown } from '@/components/Markdown';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
||||
backups: AppBackupsApiResponse;
|
||||
}
|
||||
|
||||
export const AppDetailsTabs = ({ info }: IProps) => {
|
||||
export const AppDetailsTabs = ({ info, backups }: IProps) => {
|
||||
const t = useTranslations();
|
||||
const appStatus = useAppStatus((state) => state.statuses[info.id]) || 'missing';
|
||||
|
||||
@ -23,6 +26,9 @@ export const AppDetailsTabs = ({ info }: IProps) => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="description">{t('APP_DETAILS_DESCRIPTION')}</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('APP_DETAILS_BASE_INFO')}</TabsTrigger>
|
||||
<TabsTrigger value="backups" disabled={appStatus === 'missing'}>
|
||||
{t('APP_BACKUPS_TAB_TITLE')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" disabled={appStatus === 'missing'}>
|
||||
{t('APP_LOGS_TAB_TITLE')}
|
||||
</TabsTrigger>
|
||||
@ -43,6 +49,9 @@ export const AppDetailsTabs = ({ info }: IProps) => {
|
||||
)}
|
||||
<Markdown className="markdown">{info.description}</Markdown>
|
||||
</TabsContent>
|
||||
<TabsContent value="backups">
|
||||
<AppBackups info={info} initialData={backups} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info">
|
||||
<DataGrid>
|
||||
<DataGridItem title={t('APP_DETAILS_SOURCE_CODE')}>
|
||||
|
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const BackupModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('APP_BACKUP_TITLE', { name: info.name })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<span className="text-muted">{t('APP_BACKUP_SUBTITLE')}</span>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm} intent="success">
|
||||
{t('APP_BACKUP_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { BackupModal } from './BackupModal';
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { AppBackup } from '@/api/app-backups/route';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useDateFormat } from '@/components/DateFormat/DateFormat';
|
||||
|
||||
interface IProps {
|
||||
backup?: AppBackup | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteBackupModal: React.FC<IProps> = ({ backup, isOpen, onClose, onConfirm }) => {
|
||||
const t = useTranslations();
|
||||
const formatDate = useDateFormat();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent type="danger" size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('DELETE_BACKUP_MODAL_TITLE')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-center py-4">
|
||||
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
|
||||
<h3>{t('DELETE_BACKUP_MODAL_WARNING', { id: backup?.id, date: formatDate(backup?.date) })}</h3>
|
||||
<div className="text-muted">{t('DELETE_BACKUP_MODAL_SUBTITLE')}</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm} intent="danger">
|
||||
{t('DELETE_BACKUP_MODAL_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { AppBackup } from '@/api/app-backups/route';
|
||||
import { useDateFormat } from '@/components/DateFormat/DateFormat';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
interface IProps {
|
||||
backup?: AppBackup | null;
|
||||
appName: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const RestoreModal: React.FC<IProps> = ({ appName, backup, isOpen, onClose, onConfirm }) => {
|
||||
const t = useTranslations();
|
||||
const formatDate = useDateFormat();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('APP_RESTORE_TITLE', { name: appName })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-center py-4">
|
||||
<IconAlertTriangle className="icon mb-2 text-warning icon-lg" />
|
||||
<h3>{t('APP_RESTORE_WARNING', { id: backup?.id, date: formatDate(backup?.date) })}</h3>
|
||||
<div className="text-muted">{t('APP_RESTORE_SUBTITLE')}</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm} intent="warning">
|
||||
{t('APP_RESTORE_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { RestoreModal } from './RestoreModal';
|
@ -37,7 +37,7 @@ export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, on
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('APP_UPDATE_SETTINGS_FORM_TITLE', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_UPDATE_SETTINGS_FORM_TITLE', { name: info.id })}</h5>
|
||||
</DialogHeader>
|
||||
<ScrollArea maxHeight={500}>
|
||||
<DialogDescription>
|
||||
|
@ -6,6 +6,7 @@ import { getTranslator } from '@/lib/get-translator';
|
||||
import { MessageKey, TranslatedError } from '@/server/utils/errors';
|
||||
import { appCatalog } from '@/server/services/app-catalog/app-catalog.service';
|
||||
import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
|
||||
import { appBackupService } from '@/server/services/app-backup/app-backup.service';
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||
return {
|
||||
@ -16,9 +17,10 @@ export async function generateMetadata({ params }: { params: { id: string } }):
|
||||
export default async function AppDetailsPage({ params }: { params: { id: string } }) {
|
||||
try {
|
||||
const app = await appCatalog.executeCommand('getApp', params.id);
|
||||
const backups = await appBackupService.executeCommand('getAppBackups', { appId: params.id, pageSize: 5, page: 1 });
|
||||
const settings = TipiConfig.getSettings();
|
||||
|
||||
return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
|
||||
return <AppDetailsContainer app={app} localDomain={settings.localDomain} backups={backups} />;
|
||||
} catch (e) {
|
||||
const translator = await getTranslator();
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useInfiniteQuery } from 'react-query';
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { AppStoreApiResponse } from '@/api/app-store/route';
|
||||
import { useInfiniteScroll } from '@/client/hooks/useInfiniteScroll';
|
||||
import { EmptyPage } from '../../../../components/EmptyPage';
|
||||
@ -12,38 +12,39 @@ interface IProps {
|
||||
initialData: AppStoreApiResponse;
|
||||
}
|
||||
|
||||
async function searchApps({ search, category, pageParam }: { search?: string; category?: string; pageParam?: string }) {
|
||||
const url = new URL('/api/app-store', window.location.origin);
|
||||
|
||||
if (search) {
|
||||
url.searchParams.append('search', search);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
url.searchParams.append('category', category);
|
||||
}
|
||||
|
||||
if (pageParam) {
|
||||
url.searchParams.append('cursor', pageParam);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Problem fetching data');
|
||||
}
|
||||
return response.json() as Promise<AppStoreApiResponse>;
|
||||
}
|
||||
|
||||
export const AppStoreTable: React.FC<IProps> = ({ initialData }) => {
|
||||
const { category, search } = useAppStoreState();
|
||||
|
||||
async function searchApps({ pageParam }: { pageParam?: string }) {
|
||||
const url = new URL('/api/app-store', window.location.origin);
|
||||
|
||||
if (search) {
|
||||
url.searchParams.append('search', search);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
url.searchParams.append('category', category);
|
||||
}
|
||||
|
||||
if (pageParam) {
|
||||
url.searchParams.append('cursor', pageParam);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Problem fetching data');
|
||||
}
|
||||
return response.json() as Promise<AppStoreApiResponse>;
|
||||
}
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useInfiniteQuery({
|
||||
queryFn: (other) => searchApps({ search, category, ...other }),
|
||||
queryFn: searchApps,
|
||||
queryKey: ['app-store', search, category],
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
initialData: { pages: [initialData], pageParams: [] },
|
||||
keepPreviousData: true,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const apps = data?.pages.map((page) => page.data).flat();
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { IconCircuitResistor, IconCpu, IconDatabase } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { systemLoadSchema, type SystemLoad } from '@runtipi/shared';
|
||||
import { SystemStat } from '../SystemStat';
|
||||
|
||||
@ -22,7 +22,8 @@ async function fetchSystemStatus() {
|
||||
}
|
||||
|
||||
export const DashboardContainer: React.FC<IProps> = ({ initialData }) => {
|
||||
const { data } = useQuery<SystemLoad>('systemLoad', fetchSystemStatus, { initialData, refetchInterval: 3000 });
|
||||
const { data } = useQuery({ queryKey: ['systemLoad'], queryFn: fetchSystemStatus, initialData, refetchInterval: 3000 });
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (!data) {
|
||||
|
23
src/app/actions/backup/create-app-backup-action.ts
Normal file
23
src/app/actions/backup/create-app-backup-action.ts
Normal file
@ -0,0 +1,23 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { authActionClient } from '@/lib/safe-action';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { appBackupService } from '@/server/services/app-backup/app-backup.service';
|
||||
|
||||
const input = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Given an app id, backs up the app.
|
||||
*/
|
||||
export const createAppBackupAction = authActionClient.schema(input).action(async ({ parsedInput: { id } }) => {
|
||||
await appBackupService.executeCommand('createAppBackup', { appId: id });
|
||||
|
||||
revalidatePath('/apps');
|
||||
revalidatePath(`/app/${id}`);
|
||||
revalidatePath(`/app-store/${id}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
24
src/app/actions/backup/delete-app-backup.ts
Normal file
24
src/app/actions/backup/delete-app-backup.ts
Normal file
@ -0,0 +1,24 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { authActionClient } from '@/lib/safe-action';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { appBackupService } from '@/server/services/app-backup/app-backup.service';
|
||||
|
||||
const input = z.object({
|
||||
appId: z.string(),
|
||||
filename: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Given a backup id, deletes the backup.
|
||||
*/
|
||||
export const deleteAppBackupAction = authActionClient.schema(input).action(async ({ parsedInput: { filename, appId } }) => {
|
||||
await appBackupService.executeCommand('deleteAppBackup', { filename, appId });
|
||||
|
||||
revalidatePath('/apps');
|
||||
revalidatePath(`/app/${appId}`);
|
||||
revalidatePath(`/app-store/${appId}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
24
src/app/actions/backup/restore-app-backup-action.ts
Normal file
24
src/app/actions/backup/restore-app-backup-action.ts
Normal file
@ -0,0 +1,24 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { authActionClient } from '@/lib/safe-action';
|
||||
import { appBackupService } from '@/server/services/app-backup/app-backup.service';
|
||||
|
||||
const input = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Given an app id and a filename, restores the app to a previous state.
|
||||
*/
|
||||
export const restoreAppBackupAction = authActionClient.schema(input).action(async ({ parsedInput: { id, filename } }) => {
|
||||
await appBackupService.executeCommand('restoreAppBackup', { appId: id, filename });
|
||||
|
||||
revalidatePath('/apps');
|
||||
revalidatePath(`/app/${id}`);
|
||||
revalidatePath(`/app-store/${id}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
34
src/app/api/app-backups/route.ts
Normal file
34
src/app/api/app-backups/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { ensureUser } from '@/actions/utils/ensure-user';
|
||||
import { handleApiError } from '@/actions/utils/handle-api-error';
|
||||
import { appBackupService } from '@/server/services/app-backup/app-backup.service';
|
||||
import { TranslatedError } from '@/server/utils/errors';
|
||||
|
||||
const getAppBackups = async (searchParams: URLSearchParams) => {
|
||||
const appId = searchParams.get('appId');
|
||||
const pageSize = searchParams.get('pageSize') || 10;
|
||||
const page = searchParams.get('page') || 1;
|
||||
|
||||
if (!appId) {
|
||||
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id: appId });
|
||||
}
|
||||
|
||||
return appBackupService.executeCommand('getAppBackups', { appId, pageSize: Number(pageSize), page: Number(page) });
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await ensureUser();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const backups = await getAppBackups(searchParams);
|
||||
|
||||
return new Response(JSON.stringify(backups), { headers: { 'content-type': 'application/json' } });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export type AppBackupsApiResponse = Awaited<ReturnType<typeof getAppBackups>>;
|
||||
|
||||
export type AppBackup = AppBackupsApiResponse['data'][number];
|
@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AbstractIntlMessages, NextIntlClientProvider } from 'next-intl';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import { SocketProvider } from './SocketProvider/SocketProvider';
|
||||
import type { AppStatus } from '@/server/db/schema';
|
||||
|
@ -5,75 +5,77 @@ import { useTranslations } from 'next-intl';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAppStatus } from '@/hooks/useAppStatus';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const SocketProvider = ({ children }: PropsWithChildren) => {
|
||||
const revalidateAppMutation = useAction(revalidateAppAction);
|
||||
const t = useTranslations();
|
||||
const setAppStatus = useAppStatus((state) => state.setAppStatus);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useSocket({
|
||||
onEvent: (event, data) => {
|
||||
revalidateAppMutation.execute({ id: data.appId });
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
|
||||
switch (event) {
|
||||
case 'status_change':
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'install_success':
|
||||
toast.success(t('APP_INSTALL_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'install_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_INSTALL', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'start_success':
|
||||
toast.success(t('APP_START_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'start_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_START', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'stop_success':
|
||||
toast.success(t('APP_STOP_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'stop_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_STOP', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'uninstall_success':
|
||||
toast.success(t('APP_UNINSTALL_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'uninstall_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_UNINSTALL', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'update_success':
|
||||
toast.success(t('APP_UPDATE_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'update_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_UPDATE', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'reset_success':
|
||||
toast.success(t('APP_RESET_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'reset_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_RESET', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'restart_success':
|
||||
toast.success(t('APP_RESTART_SUCCESS', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'restart_error':
|
||||
toast.error(t('APP_ERROR_APP_FAILED_TO_RESTART', { id: data.appId }));
|
||||
data.appStatus && setAppStatus(data.appId, data.appStatus);
|
||||
break;
|
||||
case 'backup_success':
|
||||
toast.success(t('APP_BACKUP_SUCCESS', { id: data.appId }));
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-backups'] });
|
||||
break;
|
||||
case 'backup_error':
|
||||
toast.error(t('APP_BACKUP_ERROR', { id: data.appId }));
|
||||
break;
|
||||
case 'restore_success':
|
||||
toast.success(t('APP_RESTORE_SUCCESS', { id: data.appId }));
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-backups'] });
|
||||
break;
|
||||
case 'restore_error':
|
||||
toast.error(t('APP_RESTORE_ERROR', { id: data.appId }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
87
src/app/components/TablePagination/TablePagination.tsx
Normal file
87
src/app/components/TablePagination/TablePagination.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/Pagination/Pagination';
|
||||
import React from 'react';
|
||||
|
||||
type TablePaginationProps = {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
delta?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export const TablePagination = ({ totalPages, currentPage, delta = 2, onPageChange, onNext, onBack }: TablePaginationProps) => {
|
||||
const generatePages = () => {
|
||||
const pages = [];
|
||||
|
||||
let start = Math.max(2, currentPage - delta);
|
||||
let end = Math.min(totalPages - 1, currentPage + delta);
|
||||
|
||||
if (currentPage - delta <= 2) {
|
||||
start = 2;
|
||||
end = Math.min(totalPages - 1, delta * 2 + 3);
|
||||
}
|
||||
|
||||
if (currentPage + delta >= totalPages - 1) {
|
||||
start = Math.max(2, totalPages - 1 - delta * 2 - 1);
|
||||
end = totalPages - 1;
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (start > 2) {
|
||||
pages.unshift('...');
|
||||
}
|
||||
if (end < totalPages - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
pages.unshift(1);
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const pages = generatePages();
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious onClick={onBack} disabled={currentPage === 1} />
|
||||
</PaginationItem>
|
||||
{pages.map((page, index) => (
|
||||
<PaginationItem key={index} isActive={page === currentPage}>
|
||||
{page === '...' ? (
|
||||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationLink
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPageChange(Number(page));
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext onClick={onNext} disabled={currentPage === totalPages} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
36
src/client/components/DateFormat/DateFormat.tsx
Normal file
36
src/client/components/DateFormat/DateFormat.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { useClientSettings } from '@/hooks/useClientSettings';
|
||||
import { useCookies } from 'next-client-cookies';
|
||||
|
||||
type IProps = {
|
||||
date: Date | string;
|
||||
};
|
||||
|
||||
export const useDateFormat = () => {
|
||||
const cookies = useCookies();
|
||||
const { timeZone } = useClientSettings();
|
||||
|
||||
const locale = cookies.get('tipi-locale') || 'en-US';
|
||||
|
||||
const formatDate = (date?: Date | string) => {
|
||||
if (!date) return 'Invalid date';
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (Number.isNaN(parsedDate.getTime())) return 'Invalid date';
|
||||
|
||||
return new Date(date).toLocaleString(locale, { timeZone });
|
||||
};
|
||||
|
||||
return formatDate;
|
||||
};
|
||||
|
||||
export const DateFormat = ({ date }: IProps) => {
|
||||
const cookies = useCookies();
|
||||
const { timeZone } = useClientSettings();
|
||||
|
||||
const locale = cookies.get('tipi-locale') || 'en-US';
|
||||
|
||||
const formattedDate = new Date(date).toLocaleString(locale, { timeZone });
|
||||
|
||||
return <>{formattedDate}</>;
|
||||
};
|
23
src/client/components/FileSize/FileSize.tsx
Normal file
23
src/client/components/FileSize/FileSize.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
type IProps = {
|
||||
size: number;
|
||||
};
|
||||
|
||||
export const FileSize = ({ size }: IProps) => {
|
||||
const fileSize = formatBytes(size);
|
||||
|
||||
return <>{fileSize}</>;
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
.paginationLink {
|
||||
width: 26px;
|
||||
max-width: 26px;
|
||||
min-width: 26px;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
border: 0px;
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import * as React from 'react';
|
||||
|
||||
import { ButtonProps } from '@/components/ui/Button';
|
||||
import clsx from 'clsx';
|
||||
import { IconChevronLeft, IconChevronRight, IconDots } from '@tabler/icons-react';
|
||||
import { Button, type ButtonProps } from '../Button';
|
||||
import styles from './Pagination.module.scss';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav role="navigation" aria-label="pagination" className={clsx('m-0 ms-auto', className)} {...props} />
|
||||
@ -15,39 +15,43 @@ const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProp
|
||||
));
|
||||
PaginationContent.displayName = 'PaginationContent';
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={clsx('page-item', className)} {...props} />
|
||||
));
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'> & { isActive?: boolean }>(
|
||||
({ className, isActive, ...props }, ref) => <li ref={ref} className={clsx('page-item', { active: isActive }, className)} {...props} />,
|
||||
);
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
small?: boolean;
|
||||
} & ButtonProps;
|
||||
|
||||
const PaginationLink = ({ className, isActive, ...props }: PaginationLinkProps) => (
|
||||
<a aria-current={isActive ? 'page' : undefined} className={clsx('page-link', { active: isActive }, className)} {...props} />
|
||||
const PaginationLink = ({ className, small = true, disabled, ...props }: PaginationLinkProps) => (
|
||||
<Button
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={clsx('page-link cursor-pointer', { [`${styles.paginationLink}`]: small }, styles.pageButton, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={clsx('gap-1 pl-2.5', className)} {...props}>
|
||||
<PaginationLink aria-label="Go to previous page" small={false} className={clsx('', className)} {...props}>
|
||||
<IconChevronLeft className="" />
|
||||
<span>prev</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={clsx('gap-1 pr-2.5', className)} {...props}>
|
||||
<span>next</span>
|
||||
<PaginationLink aria-label="Go to next page" small={false} className={clsx('', className)} {...props}>
|
||||
<IconChevronRight className="" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span aria-hidden className={clsx('d-flex align-items-center justify-content-center h-100', className)} {...props}>
|
||||
<span aria-hidden className={clsx('px-1 d-flex align-items-center justify-content-center h-100', styles.paginationLink, className)} {...props}>
|
||||
<IconDots size={14} className="mx-1" />
|
||||
</span>
|
||||
);
|
||||
|
@ -14,7 +14,7 @@ const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttribut
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={clsx('', className)} {...props} />
|
||||
<tbody ref={ref} className={clsx('table-tbody', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
|
@ -91,6 +91,17 @@
|
||||
"APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.",
|
||||
"APP_RESET_SUCCESS": "App {id} reset successfully",
|
||||
"APP_START_SUCCESS": "App {id} started successfully",
|
||||
"APP_BACKUP_TITLE": "Backup {name}",
|
||||
"APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.",
|
||||
"APP_BACKUP_SUBMIT": "Backup",
|
||||
"APP_RESTORE_TITLE": "Restore {name} backup",
|
||||
"APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?",
|
||||
"APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.",
|
||||
"APP_RESTORE_SUBMIT": "Restore",
|
||||
"APP_BACKUPS_TAB_TITLE": "Backups",
|
||||
"APP_RESTORE_SETTINGS_SUBTITILE": "Restore your app to an older state",
|
||||
"APP_SETTINGS_GENERAL_TITLE": "General",
|
||||
"APP_SETTINGS_BACKUPS_TITLE": "Backups",
|
||||
"APP_STATUS_INSTALLING": "Installing",
|
||||
"APP_STATUS_MISSING": "Missing",
|
||||
"APP_STATUS_RESETTING": "Resetting",
|
||||
@ -101,6 +112,8 @@
|
||||
"APP_STATUS_RESTARTING": "Restarting",
|
||||
"APP_STATUS_UNINSTALLING": "Uninstalling",
|
||||
"APP_STATUS_UPDATING": "Updating",
|
||||
"APP_STATUS_BACKING_UP": "Backing up",
|
||||
"APP_STATUS_RESTORING": "Restoring",
|
||||
"APP_STOP_FORM_SUBMIT": "Stop",
|
||||
"APP_STOP_FORM_SUBTITLE": "All data will be retained",
|
||||
"APP_STOP_FORM_TITLE": "Stop {name} ?",
|
||||
@ -127,6 +140,10 @@
|
||||
"APP_UPDATE_FORM_TITLE": "Update {name} ?",
|
||||
"APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config",
|
||||
"APP_UPDATE_SUCCESS": "App {id} updated successfully",
|
||||
"APP_BACKUP_SUCCESS": "App {id} backed up successfully",
|
||||
"APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details",
|
||||
"APP_RESTORE_SUCCESS": "App {id} restore successfully",
|
||||
"APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details",
|
||||
"AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.",
|
||||
"AUTH_ERROR_ERROR_CREATING_USER": "Error creating user",
|
||||
"AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials",
|
||||
@ -173,6 +190,13 @@
|
||||
"AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app",
|
||||
"AUTH_TOTP_SUBMIT": "Confirm",
|
||||
"AUTH_TOTP_TITLE": "Two-factor authentication",
|
||||
"BACKUPS_LIST": "Backups list",
|
||||
"BACKUPS_LIST_BACKUP_NOW": "Backup now",
|
||||
"BACKUPS_LIST_ROW_TITLE_ID": "ID",
|
||||
"BACKUPS_LIST_ROW_TITLE_DATE": "Date",
|
||||
"BACKUPS_LIST_ROW_TITLE_ACTIONS": "Actions",
|
||||
"BACKUPS_LIST_ROW_TITLE_SIZE": "Size",
|
||||
"BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully",
|
||||
"COMMON_CLOSE": "Close",
|
||||
"DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load",
|
||||
"DASHBOARD_CPU_TITLE": "CPU load",
|
||||
@ -182,6 +206,10 @@
|
||||
"DASHBOARD_TITLE": "Dashboard",
|
||||
"DASHBOARD_IP_WARNING_TITLE": "Insecure configuration",
|
||||
"DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers",
|
||||
"DELETE_BACKUP_MODAL_TITLE": "Delete backup",
|
||||
"DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?",
|
||||
"DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone",
|
||||
"DELETE_BACKUP_MODAL_SUBMIT": "Delete",
|
||||
"GUEST_DASHBOARD": "Guest dashboard",
|
||||
"GUEST_DASHBOARD_NO_APPS": "No apps to display",
|
||||
"GUEST_DASHBOARD_NO_APPS_SUBTITLE": "Ask your administrator to add apps to the guest dashboard or login to see your apps.",
|
||||
@ -199,16 +227,16 @@
|
||||
"INTERNAL_SERVER_ERROR": "Internal server error",
|
||||
"LINKS_ADD_SUBMIT": "Submit",
|
||||
"LINKS_ADD_SUBTITLE": "Add external link to the dashboard",
|
||||
"LINKS_ADD_SUCCESS": "Link added succesfully",
|
||||
"LINKS_ADD_SUCCESS": "Link added successfully",
|
||||
"LINKS_ADD_TITLE": "Add external link",
|
||||
"LINKS_DELETE_CONTEXT_MENU": "Delete",
|
||||
"LINKS_DELETE_SUBMIT": "Delete",
|
||||
"LINKS_DELETE_SUBTITLE": "Are you sure you want to delete this external link?",
|
||||
"LINKS_DELETE_SUCCESS": "Link deleted succesfully",
|
||||
"LINKS_DELETE_SUCCESS": "Link deleted successfully",
|
||||
"LINKS_DELETE_TITLE": "Delete external link",
|
||||
"LINKS_EDIT_CONTEXT_MENU": "Edit",
|
||||
"LINKS_EDIT_SUBMIT": "Save",
|
||||
"LINKS_EDIT_SUCCESS": "Link edited succesfully",
|
||||
"LINKS_EDIT_SUCCESS": "Link edited successfully",
|
||||
"LINKS_EDIT_TITLE": "Edit link",
|
||||
"LINKS_FORM_ICON_PLACEHOLDER": "Link logo URL",
|
||||
"LINKS_FORM_ICON_URL": "Icon URL",
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Store = {
|
||||
pollStatus: boolean;
|
||||
version: { current: string; latest?: string };
|
||||
setVersion: (version: { current: string; latest?: string }) => void;
|
||||
setPollStatus: (pollStatus: boolean) => void;
|
||||
};
|
||||
|
||||
export const useSystemStore = create<Store>((set) => ({
|
||||
status: 'RUNNING',
|
||||
version: { current: '0.0.0', latest: '0.0.0' },
|
||||
pollStatus: false,
|
||||
setVersion: (version: { current: string; latest?: string }) => set((state) => ({ ...state, version })),
|
||||
setPollStatus: (pollStatus: boolean) => set((state) => ({ ...state, pollStatus })),
|
||||
}));
|
@ -5,17 +5,14 @@ import englishMessages from '../messages/en.json';
|
||||
const defaultTranslator = createTranslator({ locale: 'en', messages: englishMessages });
|
||||
|
||||
type UIStore = {
|
||||
menuItem: string;
|
||||
darkMode: boolean;
|
||||
theme?: string;
|
||||
translator: typeof defaultTranslator;
|
||||
setMenuItem: (menuItem: string) => void;
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
setTranslator: (translator: typeof defaultTranslator) => void;
|
||||
};
|
||||
|
||||
export const useUIStore = create<UIStore>((set) => ({
|
||||
menuItem: 'dashboard',
|
||||
darkMode: false,
|
||||
translator: defaultTranslator,
|
||||
theme: undefined,
|
||||
@ -30,7 +27,4 @@ export const useUIStore = create<UIStore>((set) => ({
|
||||
}
|
||||
set({ darkMode });
|
||||
},
|
||||
setMenuItem: (menuItem: string) => {
|
||||
set({ menuItem });
|
||||
},
|
||||
}));
|
||||
|
@ -13,6 +13,8 @@ const appStatusEnum = pgEnum('app_status_enum', [
|
||||
'uninstalling',
|
||||
'resetting',
|
||||
'restarting',
|
||||
'backing_up',
|
||||
'restoring',
|
||||
]);
|
||||
|
||||
const APP_STATUS = appStatusEnum.enumValues;
|
||||
|
64
src/server/services/app-backup/app-backup.service.ts
Normal file
64
src/server/services/app-backup/app-backup.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
|
||||
import { IAppBackupCommand } from './commands/types';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import { CreateAppBackupCommand, DeleteAppBackupCommand, GetAppBackupsCommand, RestoreAppBackupCommand } from './commands';
|
||||
|
||||
export const availableCommands = {
|
||||
createAppBackup: CreateAppBackupCommand,
|
||||
restoreAppBackup: RestoreAppBackupCommand,
|
||||
getAppBackups: GetAppBackupsCommand,
|
||||
deleteAppBackup: DeleteAppBackupCommand,
|
||||
} as const;
|
||||
|
||||
export type ExecuteAppBackupFunction = <K extends keyof typeof availableCommands>(
|
||||
command: K,
|
||||
...args: Parameters<(typeof availableCommands)[K]['prototype']['execute']>
|
||||
) => Promise<ReturnType<(typeof availableCommands)[K]['prototype']['execute']>>;
|
||||
|
||||
class CommandInvoker {
|
||||
public async execute(command: IAppBackupCommand, args: unknown[]) {
|
||||
return command.execute(...args);
|
||||
}
|
||||
}
|
||||
|
||||
export class AppBackupClass {
|
||||
private commandInvoker: CommandInvoker;
|
||||
|
||||
constructor(
|
||||
private queries: AppQueries,
|
||||
private eventDispatcher: EventDispatcher,
|
||||
private appDataService: AppDataService,
|
||||
) {
|
||||
this.commandInvoker = new CommandInvoker();
|
||||
}
|
||||
|
||||
public executeCommand: ExecuteAppBackupFunction = (command, ...args) => {
|
||||
const Command = availableCommands[command];
|
||||
|
||||
if (!Command) {
|
||||
throw new Error(`Command ${command} not found`);
|
||||
}
|
||||
|
||||
type ReturnValue = Awaited<ReturnType<InstanceType<typeof Command>['execute']>>;
|
||||
|
||||
const constructed = new Command({
|
||||
queries: this.queries,
|
||||
eventDispatcher: this.eventDispatcher,
|
||||
appDataService: this.appDataService,
|
||||
executeOtherCommand: this.executeCommand,
|
||||
});
|
||||
|
||||
return this.commandInvoker.execute(constructed, args) as Promise<ReturnValue>;
|
||||
};
|
||||
}
|
||||
|
||||
export type AppBackup = InstanceType<typeof AppBackupClass>;
|
||||
|
||||
const queries = new AppQueries();
|
||||
const eventDispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });
|
||||
|
||||
export const appBackupService = new AppBackupClass(queries, eventDispatcher, appDataService);
|
@ -0,0 +1,49 @@
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { AppBackupCommandParams, IAppBackupCommand } from './types';
|
||||
import { EventDispatcher } from '@/server/core/EventDispatcher';
|
||||
import { Logger } from '@/server/core/Logger';
|
||||
import { TranslatedError } from '@/server/utils/errors';
|
||||
import { AppStatus } from '@/server/db/schema';
|
||||
|
||||
export class CreateAppBackupCommand implements IAppBackupCommand {
|
||||
private queries: AppQueries;
|
||||
private eventDispatcher: EventDispatcher;
|
||||
private executeOtherCommand: IAppBackupCommand['execute'];
|
||||
|
||||
constructor(params: AppBackupCommandParams) {
|
||||
this.queries = params.queries;
|
||||
this.eventDispatcher = params.eventDispatcher;
|
||||
this.executeOtherCommand = params.executeOtherCommand;
|
||||
}
|
||||
|
||||
private async sendEvent(appId: string, appStatusBeforeUpdate: AppStatus): Promise<void> {
|
||||
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync({ type: 'app', command: 'backup', appid: appId, form: {} });
|
||||
|
||||
if (success) {
|
||||
if (appStatusBeforeUpdate === 'running') {
|
||||
await this.executeOtherCommand('startApp', { appId });
|
||||
} else {
|
||||
await this.queries.updateApp(appId, { status: appStatusBeforeUpdate });
|
||||
}
|
||||
|
||||
await this.queries.updateApp(appId, { status: 'running' });
|
||||
} else {
|
||||
Logger.error(`Failed to backup app ${appId}: ${stdout}`);
|
||||
await this.queries.updateApp(appId, { status: 'stopped' });
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: { appId: string }): Promise<void> {
|
||||
const { appId } = params;
|
||||
const app = await this.queries.getApp(appId);
|
||||
|
||||
if (!app) {
|
||||
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id: appId });
|
||||
}
|
||||
|
||||
// Run script
|
||||
await this.queries.updateApp(appId, { status: 'backing_up' });
|
||||
|
||||
void this.sendEvent(appId, app.status);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { AppBackupCommandParams, IAppBackupCommand } from './types';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
|
||||
export class DeleteAppBackupCommand implements IAppBackupCommand {
|
||||
private appDataService: AppDataService;
|
||||
|
||||
constructor(params: AppBackupCommandParams) {
|
||||
this.appDataService = params.appDataService;
|
||||
}
|
||||
|
||||
async execute(params: { appId: string; filename: string }): Promise<void> {
|
||||
const { appId, filename } = params;
|
||||
|
||||
await this.appDataService.deleteAppBackup(appId, filename);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { AppBackupCommandParams, IAppBackupCommand } from './types';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
|
||||
type ReturnValue = Awaited<ReturnType<InstanceType<typeof GetAppBackupsCommand>['execute']>>;
|
||||
|
||||
export class GetAppBackupsCommand implements IAppBackupCommand<ReturnValue> {
|
||||
private appDataService: AppDataService;
|
||||
|
||||
constructor(params: AppBackupCommandParams) {
|
||||
this.appDataService = params.appDataService;
|
||||
}
|
||||
|
||||
async execute(params: { appId: string; pageSize: number; page: number }) {
|
||||
return this.appDataService.getAppBackups(params);
|
||||
}
|
||||
}
|
4
src/server/services/app-backup/commands/index.ts
Normal file
4
src/server/services/app-backup/commands/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { CreateAppBackupCommand } from './create-app-backup-command';
|
||||
export { RestoreAppBackupCommand } from './restore-app-backup-command';
|
||||
export { GetAppBackupsCommand } from './get-app-backups-command';
|
||||
export { DeleteAppBackupCommand } from './delete-app-backup-command';
|
@ -0,0 +1,38 @@
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { AppBackupCommandParams, IAppBackupCommand } from './types';
|
||||
import { EventDispatcher } from '@/server/core/EventDispatcher';
|
||||
import { Logger } from '@/server/core/Logger';
|
||||
import { TranslatedError } from '@/server/utils/errors';
|
||||
|
||||
export class RestoreAppBackupCommand implements IAppBackupCommand {
|
||||
private queries: AppQueries;
|
||||
private eventDispatcher: EventDispatcher;
|
||||
|
||||
constructor(params: AppBackupCommandParams) {
|
||||
this.queries = params.queries;
|
||||
this.eventDispatcher = params.eventDispatcher;
|
||||
}
|
||||
|
||||
private async sendEvent(appId: string, filename: string): Promise<void> {
|
||||
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync({ type: 'app', command: 'restore', appid: appId, filename });
|
||||
|
||||
await this.queries.updateApp(appId, { status: 'stopped' });
|
||||
if (!success) {
|
||||
Logger.error(`Failed to restore app ${appId}: ${stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: { appId: string; filename: string }): Promise<void> {
|
||||
const { appId, filename } = params;
|
||||
const app = await this.queries.getApp(appId);
|
||||
|
||||
if (!app) {
|
||||
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id: appId });
|
||||
}
|
||||
|
||||
// Run script
|
||||
await this.queries.updateApp(appId, { status: 'restoring' });
|
||||
|
||||
void this.sendEvent(appId, filename);
|
||||
}
|
||||
}
|
14
src/server/services/app-backup/commands/types.ts
Normal file
14
src/server/services/app-backup/commands/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { EventDispatcher } from '@/server/core/EventDispatcher';
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
|
||||
export interface IAppBackupCommand<T = unknown> {
|
||||
execute(...args: unknown[]): Promise<T>;
|
||||
}
|
||||
|
||||
export type AppBackupCommandParams = {
|
||||
queries: AppQueries;
|
||||
eventDispatcher: EventDispatcher;
|
||||
appDataService: AppDataService;
|
||||
executeOtherCommand: IAppBackupCommand['execute'];
|
||||
};
|
@ -2,7 +2,7 @@ import fs from 'fs-extra';
|
||||
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import path from 'path';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { beforeEach, beforeAll, afterAll, describe, it, expect } from 'vitest';
|
||||
import { AppCatalogClass } from './app-catalog.service';
|
||||
import { createAppConfig, insertApp } from '../../tests/apps.factory';
|
||||
@ -20,7 +20,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const appDataService = new AppDataService(DATA_DIR, TipiConfig.getConfig().appsRepoId);
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });
|
||||
appCatalog = new AppCatalogClass(new AppQueries(db.db), new AppCatalogCache(appDataService), appDataService);
|
||||
await clearDatabase(db);
|
||||
await TipiConfig.setConfig('version', 'test');
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { GetInstalledAppsCommand, GetGuestDashboardApps, GetAppCommand } from './commands';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import { SearchAppsCommand } from './commands/search-apps-command';
|
||||
import { ListAppsCommand } from './commands/list-apps-command';
|
||||
@ -60,7 +60,7 @@ export class AppCatalogClass {
|
||||
export type AppCatalog = InstanceType<typeof AppCatalogClass>;
|
||||
|
||||
const queries = new AppQueries();
|
||||
const appDataService = new AppDataService(DATA_DIR, TipiConfig.getConfig().appsRepoId);
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });
|
||||
const appCacheManager = new AppCatalogCache(appDataService);
|
||||
|
||||
export const appCatalog = new AppCatalogClass(queries, appCacheManager, appDataService);
|
||||
|
@ -17,7 +17,7 @@ export class GetAppCommand implements IAppCatalogCommand<ReturnValue> {
|
||||
|
||||
async execute(appId: string) {
|
||||
let app = await this.queries.getApp(appId);
|
||||
const info = await this.appDataService.getAppInfoFromInstalledOrAppStore(appId, app?.status);
|
||||
const info = await this.appDataService.getAppInfoFromInstalledOrAppStore(appId);
|
||||
const updateInfo = await this.appDataService.getUpdateInfo(appId);
|
||||
|
||||
if (info) {
|
||||
|
@ -7,13 +7,13 @@ import { TipiConfig } from '../../core/TipiConfig';
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import waitForExpect from 'wait-for-expect';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
let appLifecycle: AppLifecycleClass;
|
||||
const TEST_SUITE = 'applifecycle';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await createDatabase(TEST_SUITE);
|
||||
|
@ -2,7 +2,7 @@ import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
|
||||
import { IAppLifecycleCommand } from './commands/types';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import {
|
||||
InstallAppCommand,
|
||||
@ -72,6 +72,6 @@ export type AppLifecycle = InstanceType<typeof AppLifecycleClass>;
|
||||
|
||||
const queries = new AppQueries();
|
||||
const eventDispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, TipiConfig.getConfig().appsRepoId);
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });
|
||||
|
||||
export const appLifecycle = new AppLifecycleClass(queries, eventDispatcher, appDataService);
|
||||
|
@ -9,13 +9,13 @@ import { InstallAppCommand } from '../install-app-command';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import path from 'path';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'installappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
const executeOtherCommandMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||
let installApp: InstallAppCommand;
|
||||
|
||||
|
@ -6,12 +6,12 @@ import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/se
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { ResetAppCommand } from '../reset-app-command';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'resetappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let startApp: ResetAppCommand;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -6,12 +6,12 @@ import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/se
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { RestartAppCommand } from '../restart-app-command';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'stopappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let restartApp: RestartAppCommand;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -6,12 +6,12 @@ import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/se
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { StartAppCommand } from '../start-app-command';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'startappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let startApp: StartAppCommand;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -6,12 +6,12 @@ import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/se
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { StopAppCommand } from '../stop-app-command';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'stopappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let stopApp: StopAppCommand;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -6,12 +6,12 @@ import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/se
|
||||
import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { UninstallAppCommand } from '../uninstall-app-command';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'uninstallappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let uninstallApp: UninstallAppCommand;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -7,12 +7,12 @@ import { AppQueries } from '@/server/queries/apps/apps.queries';
|
||||
import { UpdateAppCommand } from '../update-app-command';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'updateappcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let updateApp: UpdateAppCommand;
|
||||
|
||||
const executeOtherCommandMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||
|
@ -8,13 +8,13 @@ import { faker } from '@faker-js/faker';
|
||||
import { castAppConfig } from '@/lib/helpers/castAppConfig';
|
||||
import { UpdateAppConfigCommand } from '../update-app-config-command';
|
||||
import path from 'path';
|
||||
import { DATA_DIR } from '@/config/constants';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { AppDataService } from '@runtipi/shared/node';
|
||||
|
||||
let db: TestDatabase;
|
||||
const TEST_SUITE = 'updateappconfigcommand';
|
||||
const dispatcher = new EventDispatcher();
|
||||
const appDataService = new AppDataService(DATA_DIR, 'repo-id');
|
||||
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: 'repo-id' });
|
||||
let updateAppConfig: UpdateAppConfigCommand;
|
||||
const executeOtherCommandMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||
|
||||
|
@ -45,7 +45,6 @@ export class UpdateAppCommand implements IAppLifecycleCommand {
|
||||
async execute(params: { appId: string }): Promise<void> {
|
||||
const { appId } = params;
|
||||
const app = await this.queries.getApp(appId);
|
||||
const appStatusBeforeUpdate = app?.status;
|
||||
|
||||
if (!app) {
|
||||
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id: appId });
|
||||
@ -60,6 +59,6 @@ export class UpdateAppCommand implements IAppLifecycleCommand {
|
||||
|
||||
await this.queries.updateApp(appId, { status: 'updating' });
|
||||
|
||||
void this.sendEvent(appId, castAppConfig(app.config), appStatusBeforeUpdate || 'missing');
|
||||
void this.sendEvent(appId, castAppConfig(app.config), app.status || 'missing');
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user