Remove Unraid API log download helper

This commit is contained in:
Eli Bosley
2025-11-18 20:46:37 -05:00
parent e2fdf6cadb
commit c78197052e
16 changed files with 21 additions and 620 deletions

View File

@@ -34,7 +34,6 @@ describe('RestController', () => {
{
provide: RestService,
useValue: {
getLogs: vi.fn(),
getCustomizationStream: vi.fn(),
},
},

View File

@@ -1,8 +1,8 @@
import { Controller, Get, Logger, Param, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import escapeHtml from 'escape-html';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import escapeHtml from 'escape-html';
import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
@@ -29,21 +29,6 @@ export class RestController {
return 'OK';
}
@Get('/graphql/api/logs')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.LOGS,
})
async getLogs(@Res() res: FastifyReply) {
try {
const logStream = await this.restService.getLogs();
return res.status(200).type('application/x-gtar').send(logStream);
} catch (error: unknown) {
this.logger.error(error);
return res.status(500).send(`Error: Failed to get logs`);
}
}
@Get('/graphql/api/customizations/:type')
@UsePermissions({
action: AuthAction.READ_ANY,

View File

@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
@Module({
imports: [RCloneModule, CliServicesModule, SsoModule],
imports: [RCloneModule, SsoModule],
controllers: [RestController],
providers: [RestService],
})

View File

@@ -1,26 +1,17 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
describe('RestService', () => {
let service: RestService;
beforeEach(async () => {
const mockApiReportService = {
generateReport: vi.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
RestService,
{
provide: ApiReportService,
useValue: mockApiReportService,
},
],
}).compile();

View File

@@ -1,25 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import type { ReadStream, Stats } from 'node:fs';
import type { ReadStream } from 'node:fs';
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { execa, ExecaError } from 'execa';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js';
import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { getters } from '@app/store/index.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
vi.mock('node:fs');
vi.mock('node:fs/promises');
vi.mock('execa');
vi.mock('@app/store/index.js');
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
getBannerPathIfPresent: vi.fn(),
getCasePathIfPresent: vi.fn(),
@@ -27,323 +16,69 @@ vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
describe('RestService', () => {
let service: RestService;
let apiReportService: ApiReportService;
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
RestService,
{
provide: ApiReportService,
useValue: {
generateReport: vi.fn(),
},
},
],
providers: [RestService],
}).compile();
service = module.get<RestService>(RestService);
apiReportService = module.get<ApiReportService>(ApiReportService);
});
describe('getLogs', () => {
const mockLogPath = '/usr/local/emhttp/logs/unraid-api';
const mockGraphqlApiLog = '/var/log/graphql-api.log';
const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz';
beforeEach(() => {
vi.mocked(getters).paths = vi.fn().mockReturnValue({
'log-base': mockLogPath,
});
// Mock saveApiReport to avoid side effects
vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined);
});
it('should create and return log archive successfully', async () => {
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath || path === mockZipPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getLogs();
expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], {
timeout: 60000,
reject: true,
});
expect(createReadStream).toHaveBeenCalledWith(mockZipPath);
expect(result).toBe(mockStream);
});
it('should include graphql-api.log when it exists', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream);
await service.getLogs();
expect(execa).toHaveBeenCalledWith(
'tar',
['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog],
{
timeout: 60000,
reject: true,
}
);
});
it('should handle timeout errors with detailed message', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const timeoutError = new Error('Command timed out') as ExecaError;
timeoutError.timedOut = true;
timeoutError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
timeoutError.exitCode = undefined;
timeoutError.stderr = '';
timeoutError.stdout = '';
vi.mocked(execa).mockRejectedValue(timeoutError);
await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds');
});
it('should handle command failure with exit code and stderr', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const execError = new Error('Command failed') as ExecaError;
execError.exitCode = 1;
execError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
execError.stderr = 'tar: Cannot create archive';
execError.stdout = '';
execError.shortMessage = 'Command failed with exit code 1';
vi.mocked(execa).mockRejectedValue(execError);
await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1');
await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive');
});
it('should handle case when tar succeeds but zip file is not created', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
// Zip file doesn't exist after tar command
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
await expect(service.getLogs()).rejects.toThrow(
'Failed to create log zip - tar file not found after successful command'
);
});
it('should throw error when log path does not exist', async () => {
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
await expect(service.getLogs()).rejects.toThrow('No logs to download');
});
it('should handle generic errors', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const genericError = new Error('Unexpected error');
vi.mocked(execa).mockRejectedValue(genericError);
await expect(service.getLogs()).rejects.toThrow(
'Failed to create logs archive: Unexpected error'
);
});
it('should handle errors with stdout in addition to stderr', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const execError = new Error('Command failed') as ExecaError;
execError.exitCode = 1;
execError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
execError.stderr = 'tar: Error';
execError.stdout = 'Processing archive...';
execError.shortMessage = 'Command failed with exit code 1';
vi.mocked(execa).mockRejectedValue(execError);
await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive');
});
});
describe('saveApiReport', () => {
it('should generate and save API report', async () => {
const mockReport: ApiReportData = {
timestamp: new Date().toISOString(),
connectionStatus: { running: 'yes' },
system: {
name: 'Test Server',
version: '6.12.0',
machineId: 'test-machine-id',
},
connect: {
installed: false,
},
config: {
valid: true,
},
services: {
cloud: null,
minigraph: null,
allServices: [],
},
};
const mockPath = '/test/report.json';
vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport);
vi.mocked(writeFile).mockResolvedValue(undefined);
await service.saveApiReport(mockPath);
expect(apiReportService.generateReport).toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledWith(
mockPath,
JSON.stringify(mockReport, null, 2),
'utf-8'
);
});
it('should handle errors when generating report', async () => {
const mockPath = '/test/report.json';
vi.mocked(apiReportService.generateReport).mockRejectedValue(
new Error('Report generation failed')
);
// Should not throw, just log warning
await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined();
expect(apiReportService.generateReport).toHaveBeenCalled();
});
});
describe('getCustomizationPath', () => {
it('should return banner path when type is banner', async () => {
it('returns banner path when present', async () => {
const mockBannerPath = '/path/to/banner.png';
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
const result = await service.getCustomizationPath('banner');
expect(getBannerPathIfPresent).toHaveBeenCalled();
expect(result).toBe(mockBannerPath);
await expect(service.getCustomizationPath('banner')).resolves.toBe(mockBannerPath);
});
it('should return case path when type is case', async () => {
it('returns case path when present', async () => {
const mockCasePath = '/path/to/case.png';
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
const result = await service.getCustomizationPath('case');
expect(getCasePathIfPresent).toHaveBeenCalled();
expect(result).toBe(mockCasePath);
await expect(service.getCustomizationPath('case')).resolves.toBe(mockCasePath);
});
it('should return null when no banner found', async () => {
it('returns null when no path is available', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
const result = await service.getCustomizationPath('banner');
expect(result).toBeNull();
});
it('should return null when no case found', async () => {
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
const result = await service.getCustomizationPath('case');
expect(result).toBeNull();
await expect(service.getCustomizationPath('banner')).resolves.toBeNull();
await expect(service.getCustomizationPath('case')).resolves.toBeNull();
});
});
describe('getCustomizationStream', () => {
it('should return read stream for banner', async () => {
it('returns read stream for banner', async () => {
const mockPath = '/path/to/banner.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getCustomizationStream('banner');
expect(getBannerPathIfPresent).toHaveBeenCalled();
await expect(service.getCustomizationStream('banner')).resolves.toBe(mockStream);
expect(createReadStream).toHaveBeenCalledWith(mockPath);
expect(result).toBe(mockStream);
});
it('should return read stream for case', async () => {
it('returns read stream for case', async () => {
const mockPath = '/path/to/case.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getCustomizationStream('case');
expect(getCasePathIfPresent).toHaveBeenCalled();
await expect(service.getCustomizationStream('case')).resolves.toBe(mockStream);
expect(createReadStream).toHaveBeenCalledWith(mockPath);
expect(result).toBe(mockStream);
});
it('should throw error when no banner found', async () => {
it('throws when no customization is available', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
});
it('should throw error when no case found', async () => {
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
});
});

View File

@@ -1,111 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { ReadStream } from 'node:fs';
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ExecaError } from 'execa';
import { execa } from 'execa';
import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { getters } from '@app/store/index.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers.js';
@Injectable()
export class RestService {
protected logger = new Logger(RestService.name);
constructor(private readonly apiReportService: ApiReportService) {}
async saveApiReport(pathToReport: string) {
try {
const apiReport = await this.apiReportService.generateReport();
this.logger.debug('Report object %o', apiReport);
await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8');
} catch (error) {
this.logger.warn('Could not generate report for zip with error %o', error);
}
}
async getLogs(): Promise<ReadStream> {
const logPath = getters.paths()['log-base'];
const graphqlApiLog = '/var/log/graphql-api.log';
try {
await this.saveApiReport(join(logPath, 'report.json'));
} catch (error) {
this.logger.warn('Could not generate report for zip with error %o', error);
}
const zipToWrite = join(logPath, '../unraid-api.tar.gz');
const logPathExists = Boolean(await stat(logPath).catch(() => null));
if (logPathExists) {
try {
// Build tar command arguments
const tarArgs = ['-czf', zipToWrite, logPath];
// Check if graphql-api.log exists and add it to the archive
const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null));
if (graphqlLogExists) {
tarArgs.push(graphqlApiLog);
this.logger.debug('Including graphql-api.log in archive');
}
// Execute tar with timeout and capture output
await execa('tar', tarArgs, {
timeout: 60000, // 60 seconds timeout for tar operation
reject: true, // Throw on non-zero exit (default behavior)
});
const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null));
if (tarFileExists) {
return createReadStream(zipToWrite);
} else {
throw new Error(
'Failed to create log zip - tar file not found after successful command'
);
}
} catch (error) {
// Build detailed error message with execa's built-in error info
let errorMessage = 'Failed to create logs archive';
if (error && typeof error === 'object' && 'command' in error) {
const execaError = error as ExecaError;
if (execaError.timedOut) {
errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`;
} else if (execaError.exitCode !== undefined) {
errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`;
}
// Add stderr/stdout if available
if (execaError.stderr) {
errorMessage += `. Stderr: ${execaError.stderr}`;
}
if (execaError.stdout) {
errorMessage += `. Stdout: ${execaError.stdout}`;
}
// Include the short message from execa
if (execaError.shortMessage) {
errorMessage += `. Details: ${execaError.shortMessage}`;
}
} else if (error instanceof Error) {
errorMessage += `: ${error.message}`;
}
this.logger.error(errorMessage, error);
throw new Error(errorMessage);
}
} else {
throw new Error('No logs to download');
}
}
async getCustomizationPath(type: 'banner' | 'case'): Promise<string | null> {
switch (type) {
case 'banner':

View File

@@ -1,110 +0,0 @@
/**
* DownloadApiLogs Component Test Coverage
*/
import { mount } from '@vue/test-utils';
import { BrandButton } from '@unraid/ui';
import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
import { createTestI18n, testTranslate } from '../utils/i18n';
vi.mock('~/helpers/urls', () => ({
CONNECT_FORUMS: new URL('http://mock-forums.local'),
CONTACT: new URL('http://mock-contact.local'),
DISCORD: new URL('http://mock-discord.local'),
WEBGUI_GRAPHQL: '/graphql',
}));
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('vue-i18n');
return {
...actual,
useI18n: () => ({
t: testTranslate,
}),
};
});
describe('DownloadApiLogs', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock global csrf_token
globalThis.csrf_token = 'mock-csrf-token';
});
it('provides a download button with the correct URL', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});
// Expected download URL
const expectedUrl = '/graphql/api/logs?csrf_token=mock-csrf-token';
// Find the download button
const downloadButton = wrapper.findComponent(BrandButton);
// Verify download button exists and has correct attributes
expect(downloadButton.exists()).toBe(true);
expect(downloadButton.attributes('href')).toBe(expectedUrl);
expect(downloadButton.attributes('download')).toBe('');
expect(downloadButton.attributes('target')).toBe('_blank');
expect(downloadButton.attributes('rel')).toBe('noopener noreferrer');
expect(downloadButton.text()).toContain(testTranslate('downloadApiLogs.downloadUnraidApiLogs'));
});
it('displays support links to documentation and help resources', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});
const links = wrapper.findAll('a');
expect(links.length).toBe(4);
expect(links[1].attributes('href')).toBe('http://mock-forums.local/');
expect(links[1].text()).toContain(testTranslate('downloadApiLogs.unraidConnectForums'));
expect(links[2].attributes('href')).toBe('http://mock-discord.local/');
expect(links[2].text()).toContain(testTranslate('downloadApiLogs.unraidDiscord'));
expect(links[3].attributes('href')).toBe('http://mock-contact.local/');
expect(links[3].text()).toContain(testTranslate('downloadApiLogs.unraidContactPage'));
links.slice(1).forEach((link) => {
expect(link.attributes('target')).toBe('_blank');
expect(link.attributes('rel')).toBe('noopener noreferrer');
});
});
it('displays instructions about log usage and privacy', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});
const text = wrapper.text();
expect(text).toContain(testTranslate('downloadApiLogs.thePrimaryMethodOfSupportFor'));
expect(text).toContain(testTranslate('downloadApiLogs.ifYouAreAskedToSupply'));
expect(text).toContain(testTranslate('downloadApiLogs.theLogsMayContainSensitiveInformation'));
});
});

View File

@@ -14,7 +14,6 @@ vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'Header
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' }));
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));

View File

@@ -16,9 +16,6 @@ vi.mock('~/components/Auth.standalone.vue', () => ({
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
}));
vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
}));
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
}));

View File

@@ -138,7 +138,6 @@ if ($display['theme'] === 'black' || $display['theme'] === 'azure') {
<unraid-auth></connect-auth>
</div>
<div class="ComponentWrapper">
<unraid-download-api-logs></connect-download-api-logs>
</div>
<div class="ComponentWrapper">
<unraid-key-actions></connect-key-actions>

View File

@@ -18,7 +18,6 @@ import {
updateConnectSettings,
} from '~/components/ConnectSettings/graphql/settings.query';
import OidcDebugLogs from '~/components/ConnectSettings/OidcDebugLogs.vue';
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
import { useServerStore } from '~/store/server';
// Disable automatic attribute inheritance
@@ -115,8 +114,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
<Auth />
</template>
<Label>{{ t('downloadApiLogs.downloadUnraidApiLogs') }}:</Label>
<DownloadApiLogs />
</SettingsGrid>
<!-- auto-generated settings form -->
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import { CONNECT_FORUMS, CONTACT, DISCORD, WEBGUI_GRAPHQL } from '~/helpers/urls';
const { t } = useI18n();
const joinPaths = (base: string, path: string) => {
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
return `${normalizedBase}/${normalizedPath}`;
};
const downloadUrl = computed(() => {
const csrfToken = globalThis.csrf_token ?? '';
const downloadPath = joinPaths(WEBGUI_GRAPHQL, '/api/logs');
const params = new URLSearchParams({ csrf_token: csrfToken });
return `${downloadPath}?${params.toString()}`;
});
</script>
<template>
<div class="flex max-w-3xl flex-col gap-y-4 whitespace-normal">
<p class="text-start text-sm">
{{ t('downloadApiLogs.thePrimaryMethodOfSupportFor') }}
{{ t('downloadApiLogs.ifYouAreAskedToSupply') }}
{{ t('downloadApiLogs.theLogsMayContainSensitiveInformation') }}
</p>
<span class="flex flex-col gap-y-4">
<div class="flex">
<BrandButton
class="shrink-0 grow-0"
download
:external="true"
:href="downloadUrl"
:icon="ArrowDownTrayIcon"
size="12px"
:text="t('downloadApiLogs.downloadUnraidApiLogs')"
/>
</div>
<div class="flex flex-row items-baseline gap-2">
<a
:href="CONNECT_FORUMS.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidConnectForums') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
<a
:href="DISCORD.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidDiscord') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
<a
:href="CONTACT.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidContactPage') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
</div>
</span>
</div>
</template>

View File

@@ -41,11 +41,6 @@ export const componentMappings: ComponentMapping[] = [
selector: 'unraid-connect-settings',
appId: 'connect-settings',
},
{
component: defineAsyncComponent(() => import('../DownloadApiLogs.standalone.vue')),
selector: 'unraid-download-api-logs',
appId: 'download-api-logs',
},
{
component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')),
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors

View File

@@ -122,11 +122,6 @@ watch(
<ConnectSettingsCe />
<hr class="border-muted" />
<!-- <h3 class="text-lg font-semibold font-mono">
DownloadApiLogsCe
</h3>
<DownloadApiLogsCe />
<hr class="border-black dark:border-white"> -->
<!-- <h3 class="text-lg font-semibold font-mono">
AuthCe
</h3>

View File

@@ -29,9 +29,6 @@
<div class="card">
<h2>API Logs</h2>
<p class="card-description">Download and view API access logs</p>
<div class="component-mount" data-component="unraid-download-api-logs">
<unraid-download-api-logs></unraid-download-api-logs>
</div>
</div>
<div class="card">

View File

@@ -25,7 +25,6 @@ declare global {
// These are generated for each component in componentMappings
mountAuth?: (selector?: string) => unknown;
mountConnectSettings?: (selector?: string) => unknown;
mountDownloadApiLogs?: (selector?: string) => unknown;
mountHeaderOsVersion?: (selector?: string) => unknown;
mountModals?: (selector?: string) => unknown;
mountModalsLegacy?: (selector?: string) => unknown;