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:
Stavros 2024-07-24 22:30:47 +03:00 committed by GitHub
parent c89c17b08c
commit da98f9a65b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 1518 additions and 253 deletions

1
.gitignore vendored
View File

@ -55,6 +55,7 @@ node_modules/
/repos/
/apps/
/traefik/
/backups/
# media folder
media

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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(),

View File

@ -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(),

View 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
$$;

View File

@ -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"

View 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);
});
};
}

View File

@ -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);
}

View File

@ -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();
});
});
});

View File

@ -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');
}
};
}

View File

@ -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));

View File

@ -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 });
});

View File

@ -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

View File

@ -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()

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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')}>

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export { BackupModal } from './BackupModal';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export { RestoreModal } from './RestoreModal';

View File

@ -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>

View File

@ -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();

View File

@ -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();

View File

@ -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) {

View 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 };
});

View 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 };
});

View 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 };
});

View 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];

View File

@ -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';

View File

@ -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;

View 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>
);
};

View 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}</>;
};

View 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}</>;
};

View File

@ -0,0 +1,9 @@
.paginationLink {
width: 26px;
max-width: 26px;
min-width: 26px;
}
.pageButton {
border: 0px;
}

View File

@ -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>
);

View File

@ -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';

View File

@ -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",

View File

@ -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 })),
}));

View File

@ -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 });
},
}));

View File

@ -13,6 +13,8 @@ const appStatusEnum = pgEnum('app_status_enum', [
'uninstalling',
'resetting',
'restarting',
'backing_up',
'restoring',
]);
const APP_STATUS = appStatusEnum.enumValues;

View 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);

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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';

View File

@ -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);
}
}

View 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'];
};

View File

@ -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');

View File

@ -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);

View File

@ -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) {

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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 }));

View File

@ -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 }));

View File

@ -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');
}
}