mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
fix: report issues + pm2 issues
This commit is contained in:
17
api/package-lock.json
generated
17
api/package-lock.json
generated
@@ -62,7 +62,6 @@
|
||||
"graphql-ws": "^5.16.0",
|
||||
"ini": "^4.1.2",
|
||||
"ip": "^2.0.1",
|
||||
"ip-regex": "^5.0.0",
|
||||
"jose": "^5.9.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"multi-ini": "^2.3.2",
|
||||
@@ -10924,17 +10923,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
|
||||
},
|
||||
"node_modules/ip-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"license": "MIT",
|
||||
@@ -23875,11 +23863,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ip-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw=="
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.9.1"
|
||||
},
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
"graphql-ws": "^5.16.0",
|
||||
"ini": "^4.1.2",
|
||||
"ip": "^2.0.1",
|
||||
"ip-regex": "^5.0.0",
|
||||
"jose": "^5.9.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"multi-ini": "^2.3.2",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import pm2 from 'pm2';
|
||||
|
||||
export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
|
||||
const { connect, describe, disconnect } = await import('pm2');
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.connect(function (err) {
|
||||
connect(function (err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
reject('Could not connect to pm2');
|
||||
}
|
||||
|
||||
pm2.describe('unraid-api', function (err, processDescription) {
|
||||
describe('unraid-api', function (err, processDescription) {
|
||||
console.log(err);
|
||||
if (err || processDescription.length === 0) {
|
||||
console.log(false); // Service not found or error occurred
|
||||
@@ -19,7 +18,7 @@ export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
|
||||
resolve(isOnline);
|
||||
}
|
||||
|
||||
pm2.disconnect();
|
||||
disconnect();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,212 +1,18 @@
|
||||
import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core/index.js';
|
||||
import ipRegex from 'ip-regex';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import type { getCloudQuery, getServersQuery } from '@app/graphql/generated/api/operations';
|
||||
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running';
|
||||
import { API_VERSION } from '@app/environment';
|
||||
import { getApiApolloClient } from '@app/graphql/client/api/get-api-client';
|
||||
import { getCloudDocument, getServersDocument } from '@app/graphql/generated/api/operations';
|
||||
import { MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { getters, store } from '@app/store';
|
||||
import { loadConfigFile } from '@app/store/modules/config';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp';
|
||||
import type { MyServersConfigMemory } from '@app/types/my-servers-config';
|
||||
import { getters } from '@app/store';
|
||||
import { MyServersConfigMemorySchema } from '@app/types/my-servers-config';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service';
|
||||
|
||||
type CloudQueryResult = NonNullable<ApolloQueryResult<getCloudQuery>['data']['cloud']>;
|
||||
type ServersQueryResultServer = NonNullable<ApolloQueryResult<getServersQuery>['data']['servers']>[0];
|
||||
|
||||
type ServersPayload = {
|
||||
online: ServersQueryResultServer[];
|
||||
offline: ServersQueryResultServer[];
|
||||
invalid: ServersQueryResultServer[];
|
||||
};
|
||||
|
||||
type ReportObject = {
|
||||
os: {
|
||||
serverName: string;
|
||||
version: string;
|
||||
};
|
||||
api: {
|
||||
version: string;
|
||||
status: 'running' | 'stopped';
|
||||
environment: string;
|
||||
nodeVersion: string;
|
||||
};
|
||||
apiKey: 'valid' | 'invalid' | string;
|
||||
servers?: ServersPayload | null;
|
||||
myServers: {
|
||||
status: 'authenticated' | 'signed out';
|
||||
myServersUsername?: string;
|
||||
};
|
||||
minigraph: {
|
||||
status: MinigraphStatus;
|
||||
timeout: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
cloud: {
|
||||
status: string;
|
||||
error?: string;
|
||||
ip?: string;
|
||||
allowedOrigins?: string[] | null;
|
||||
};
|
||||
};
|
||||
|
||||
// This should return the status of the apiKey and mothership
|
||||
export const getCloudData = async (
|
||||
client: ApolloClient<NormalizedCacheObject>
|
||||
): Promise<CloudQueryResult | null> => {
|
||||
const cloud = await client.query({ query: getCloudDocument });
|
||||
return cloud.data.cloud ?? null;
|
||||
};
|
||||
|
||||
export const getServersData = async ({
|
||||
client,
|
||||
verbosity,
|
||||
}: {
|
||||
client: ApolloClient<NormalizedCacheObject>;
|
||||
verbosity: number;
|
||||
}): Promise<ServersPayload | null> => {
|
||||
if (verbosity === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const servers = await client.query({ query: getServersDocument });
|
||||
const foundServers = servers.data.servers.reduce<ServersPayload>(
|
||||
(acc, curr) => {
|
||||
switch (curr.status) {
|
||||
case 'online':
|
||||
acc.online.push(curr);
|
||||
break;
|
||||
case 'offline':
|
||||
acc.offline.push(curr);
|
||||
break;
|
||||
default:
|
||||
acc.invalid.push(curr);
|
||||
break;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ online: [], offline: [], invalid: [] }
|
||||
);
|
||||
return foundServers;
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
online: [],
|
||||
offline: [],
|
||||
invalid: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const hashUrlRegex = () => /(.*)([a-z0-9]{40})(.*)/g;
|
||||
|
||||
export const anonymiseOrigins = (origins?: string[]): string[] => {
|
||||
const originsWithoutSocks = origins?.filter((url) => !url.endsWith('.sock')) ?? [];
|
||||
return originsWithoutSocks
|
||||
.map((origin) =>
|
||||
origin
|
||||
// Replace 40 char hash string with "HASH"
|
||||
.replace(hashUrlRegex(), '$1HASH$3')
|
||||
// Replace ipv4 address using . separator with "IPV4ADDRESS"
|
||||
.replace(ipRegex(), 'IPV4ADDRESS')
|
||||
// Replace ipv4 address using - separator with "IPV4ADDRESS"
|
||||
.replace(new RegExp(ipRegex().toString().replace('\\.', '-')), '/IPV4ADDRESS')
|
||||
// Report WAN port
|
||||
.replace(`:${getters.config().remote.wanport || 443}`, ':WANPORT')
|
||||
)
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getAllowedOrigins = (cloud: CloudQueryResult | null, verbosity: number): string[] | null => {
|
||||
if (verbosity > 1) {
|
||||
return cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ?? [];
|
||||
} else if (verbosity === 1) {
|
||||
return anonymiseOrigins(cloud?.allowedOrigins ?? []);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getReadableCloudDetails = (reportObject: ReportObject, verbosity: number): string => {
|
||||
const error = reportObject.cloud.error ? `\n ERROR [${reportObject.cloud.error}]` : '';
|
||||
const status = reportObject.cloud.status ? reportObject.cloud.status : 'disconnected';
|
||||
const ip = reportObject.cloud.ip && verbosity !== 0 ? `\n IP: [${reportObject.cloud.ip}]` : '';
|
||||
return `
|
||||
STATUS: [${status}] ${ip} ${error}`;
|
||||
};
|
||||
|
||||
const getReadableMinigraphDetails = (reportObject: ReportObject): string => {
|
||||
const statusLine = `STATUS: [${reportObject.minigraph.status}]`;
|
||||
const errorLine = reportObject.minigraph.error ? ` ERROR: [${reportObject.minigraph.error}]` : null;
|
||||
const timeoutLine = reportObject.minigraph.timeout
|
||||
? ` TIMEOUT: [${(reportObject.minigraph.timeout || 1) / 1_000}s]`
|
||||
: null; // 1 in case of divide by zero
|
||||
|
||||
return `
|
||||
${statusLine}${errorLine ? `\n${errorLine}` : ''}${timeoutLine ? `\n${timeoutLine}` : ''}`;
|
||||
};
|
||||
|
||||
// Convert server to string output
|
||||
const serverToString = (verbosity: number) => (server: ServersQueryResultServer) =>
|
||||
`${server?.name ?? 'No Server Name'}${
|
||||
verbosity > 0
|
||||
? `[owner="${server.owner?.username ?? 'No Owner Found'}"${
|
||||
verbosity > 1 ? ` guid="${server.guid ?? 'No GUID'}"]` : ']'
|
||||
}`
|
||||
: ''
|
||||
}`;
|
||||
|
||||
const getReadableServerDetails = (reportObject: ReportObject, verbosity: number): string => {
|
||||
if (!reportObject.servers) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (reportObject.api.status === 'stopped') {
|
||||
return '\nSERVERS: API is offline';
|
||||
}
|
||||
|
||||
const invalid =
|
||||
verbosity > 0 && reportObject.servers.invalid.length > 0
|
||||
? `
|
||||
INVALID: ${reportObject.servers.invalid.map(serverToString(verbosity)).join(',')}`
|
||||
: '';
|
||||
|
||||
return `
|
||||
SERVERS:
|
||||
ONLINE: ${reportObject.servers.online.map(serverToString(verbosity)).join(',')}
|
||||
OFFLINE: ${reportObject.servers.offline.map(serverToString(verbosity)).join(',')}${invalid}`;
|
||||
};
|
||||
|
||||
const getReadableAllowedOrigins = (reportObject: ReportObject): string => {
|
||||
const { cloud } = reportObject;
|
||||
if (cloud?.allowedOrigins) {
|
||||
return `
|
||||
ALLOWED_ORIGINS: ${cloud.allowedOrigins.join(', ').trim()}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
interface ReportOptions {
|
||||
raw: boolean;
|
||||
json: boolean;
|
||||
verbose: number;
|
||||
}
|
||||
|
||||
@Command({ name: 'report' })
|
||||
export class ReportCommand extends CommandRunner {
|
||||
constructor(private readonly logger: LogService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private defaultOptions: ReportOptions = {
|
||||
raw: false,
|
||||
json: false,
|
||||
verbose: 0,
|
||||
};
|
||||
|
||||
@Option({
|
||||
flags: '-r, --raw',
|
||||
description: 'whether to enable raw command output',
|
||||
@@ -225,158 +31,52 @@ export class ReportCommand extends CommandRunner {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-v, --verbose',
|
||||
description: 'Verbosity level (-v -vv -vvv)',
|
||||
defaultValue: 0,
|
||||
})
|
||||
handleVerbose(value: string | boolean, previous: number = 0): number {
|
||||
if (typeof value === 'boolean') {
|
||||
// Single `-v` or `--verbose` flag increments verbosity
|
||||
return previous + 1;
|
||||
} else if (value === undefined) {
|
||||
return previous + 1; // Increment if flag is used without value
|
||||
} else {
|
||||
// If `-vvv` is passed as one flag, count the number of `v`s
|
||||
return previous + value.length;
|
||||
async getBothMyServersConfigsWithoutError(): Promise<MyServersConfigMemory | null> {
|
||||
const ini = await import('ini');
|
||||
const diskConfig = await readFile(getters.paths()['myservers-config'], 'utf-8').catch(
|
||||
(_) => null
|
||||
);
|
||||
const memoryConfig = await readFile(getters.paths()['myservers-config-states'], 'utf-8').catch(
|
||||
(_) => null
|
||||
);
|
||||
|
||||
if (memoryConfig) {
|
||||
return MyServersConfigMemorySchema.parse(ini.parse(memoryConfig));
|
||||
} else if (diskConfig) {
|
||||
return MyServersConfigMemorySchema.parse(ini.parse(diskConfig));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async report(options: ReportOptions = this.defaultOptions): Promise<string | ReportObject | void> {
|
||||
// Check if we have a tty attached to stdout
|
||||
// If we don't then this is being piped to a log file, etc.
|
||||
const hasTty = process.stdout.isTTY;
|
||||
|
||||
// Check if we should show interactive logs
|
||||
// If this has a tty it's interactive
|
||||
// AND
|
||||
// If they don't have --raw
|
||||
const isInteractive = hasTty && !options.raw;
|
||||
|
||||
async report(): Promise<string | void> {
|
||||
try {
|
||||
// Show loading message
|
||||
if (isInteractive) {
|
||||
this.logger.info('Generating report please wait…');
|
||||
}
|
||||
const { isUnraidApiRunning } = await import('@app/core/utils/pm2/unraid-api-running');
|
||||
|
||||
const jsonReport = options?.json ?? false;
|
||||
const apiRunning = await isUnraidApiRunning().catch((err) => {
|
||||
this.logger.debug('failed to get PM2 state with error: ' + err);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Find all processes called "unraid-api" which aren't this process
|
||||
const unraidApiRunning = await isUnraidApiRunning();
|
||||
|
||||
// Load my servers config file into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
await store.dispatch(loadStateFiles());
|
||||
|
||||
const { config, emhttp } = store.getState();
|
||||
|
||||
const client = getApiApolloClient({ localApiKey: config.remote.localApiKey || '' });
|
||||
// Fetch the cloud endpoint
|
||||
const cloud = await getCloudData(client)
|
||||
.then((data) => {
|
||||
this.logger.debug('Cloud Data', data);
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.debug(
|
||||
'Failed fetching cloud from local graphql with "%s"',
|
||||
error instanceof Error ? error.message : 'Unknown Error'
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
// Query local graphql using upc's API key
|
||||
// Get the servers array
|
||||
const servers = await getServersData({ client, verbosity: options.verbose });
|
||||
|
||||
// Check if the API key is valid
|
||||
const isApiKeyValid = cloud?.apiKey.valid ?? false;
|
||||
|
||||
const reportObject: ReportObject = {
|
||||
os: {
|
||||
serverName: emhttp.var.name,
|
||||
version: emhttp.var.version,
|
||||
},
|
||||
api: {
|
||||
version: API_VERSION,
|
||||
status: unraidApiRunning ? 'running' : 'stopped',
|
||||
environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT',
|
||||
nodeVersion: process.version,
|
||||
},
|
||||
apiKey: isApiKeyValid ? 'valid' : (cloud?.apiKey.error ?? 'invalid'),
|
||||
...(servers ? { servers } : {}),
|
||||
myServers: {
|
||||
status: config?.remote?.username ? 'authenticated' : 'signed out',
|
||||
...(config?.remote?.username
|
||||
? {
|
||||
myServersUsername: config?.remote?.username?.includes('@')
|
||||
? 'REDACTED'
|
||||
: config?.remote.username,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
minigraph: {
|
||||
status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT,
|
||||
timeout: cloud?.minigraphql.timeout ?? null,
|
||||
error:
|
||||
(cloud?.minigraphql.error ?? !cloud?.minigraphql.status)
|
||||
? 'API Disconnected'
|
||||
: null,
|
||||
},
|
||||
cloud: {
|
||||
status: cloud?.cloud.status ?? 'error',
|
||||
...(cloud?.cloud.error ? { error: cloud.cloud.error } : {}),
|
||||
...(cloud?.cloud.status === 'ok' ? { ip: cloud.cloud.ip ?? 'NO_IP' } : {}),
|
||||
...(getAllowedOrigins(cloud, options.verbose)
|
||||
? { allowedOrigins: getAllowedOrigins(cloud, options.verbose) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (jsonReport) {
|
||||
this.logger.clear();
|
||||
this.logger.info(JSON.stringify(reportObject) + '\n');
|
||||
return reportObject;
|
||||
} else {
|
||||
// Generate the actual report
|
||||
const report = `
|
||||
<-----UNRAID-API-REPORT----->
|
||||
SERVER_NAME: ${reportObject.os.serverName}
|
||||
ENVIRONMENT: ${reportObject.api.environment}
|
||||
UNRAID_VERSION: ${reportObject.os.version}
|
||||
UNRAID_API_VERSION: ${reportObject.api.version}
|
||||
UNRAID_API_STATUS: ${reportObject.api.status}
|
||||
API_KEY: ${reportObject.apiKey}
|
||||
MY_SERVERS: ${reportObject.myServers.status}${
|
||||
reportObject.myServers.myServersUsername
|
||||
? `\nMY_SERVERS_USERNAME: ${reportObject.myServers.myServersUsername}`
|
||||
: ''
|
||||
}
|
||||
CLOUD: ${getReadableCloudDetails(reportObject, options.verbose)}
|
||||
MINI-GRAPH: ${getReadableMinigraphDetails(reportObject)}${getReadableServerDetails(
|
||||
reportObject,
|
||||
options.verbose
|
||||
)}${getReadableAllowedOrigins(reportObject)}
|
||||
</----UNRAID-API-REPORT----->
|
||||
`;
|
||||
this.logger.clear();
|
||||
|
||||
this.logger.info(report);
|
||||
return report;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
this.logger.debug(error);
|
||||
this.logger.error(`\nFailed generating report with "${error.message}"\n`);
|
||||
return;
|
||||
} else {
|
||||
this.logger.error('Failed generating report');
|
||||
return;
|
||||
}
|
||||
const config =
|
||||
(await this.getBothMyServersConfigsWithoutError()) as MyServersConfigMemory & {
|
||||
connectionStatus: { running: 'yes' | 'no' };
|
||||
};
|
||||
config.connectionStatus.running = apiRunning ? 'yes' : 'no';
|
||||
this.logger.clear();
|
||||
this.logger.info(JSON.stringify(config, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.debug('Error Generating Config: ' + error);
|
||||
this.logger.warn(
|
||||
JSON.stringify(
|
||||
{ error: 'Please ensure the API is configured before attempting to run a report' },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async run(_: string[], options?: ReportOptions): Promise<void> {
|
||||
await this.report(options);
|
||||
async run(): Promise<void> {
|
||||
await this.report();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class RestService {
|
||||
try {
|
||||
const reportCommand = new ReportCommand(new LogService());
|
||||
|
||||
const apiReport = await reportCommand.report({ json: true, verbose: 2, raw: false });
|
||||
const apiReport = await reportCommand.report();
|
||||
this.logger.debug('Report object %o', apiReport);
|
||||
await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user