Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 12 additions & 27 deletions src/StandaloneApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import StandaloneAppShell from '@/components/standalone/StandaloneAppShell.vue'
import StandaloneAppLogin from '@/components/standalone/StandaloneAppLogin.vue'
import { TOKEN_REFRESH_INTERVAL, useLoginStore } from '@/stores/standalone/standaloneLogin'
import { onMounted, ref } from 'vue'
import axios, { type AxiosRequestConfig, CanceledError } from 'axios'
import axios, { type AxiosRequestConfig } from 'axios'
import { getStandaloneApiEndpoint, isStandaloneMode } from './lib/config'
import { useUnitsStore } from './stores/controller/units'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { getPreference } from '@nethesis/vue-components'
import { useNotificationsStore } from './stores/notifications'
import { getUbusReproductionCommand } from '@/lib/axiosErrorCommand'
import { UnauthorizedAction, useSudoStore } from '@/stores/standalone/sudo.ts'
import AskSudoPasswordModal from '@/components/standalone/AskSudoPasswordModal.vue'
import WizardShell from './views/standalone/wizard/WizardShell.vue'
Expand All @@ -23,7 +23,6 @@ import { VueQueryDevtools } from '@tanstack/vue-query-devtools'

const loginStore = useLoginStore()
const unitsStore = useUnitsStore()
const notificationsStore = useNotificationsStore()
const { locale } = useI18n({ useScope: 'global' })
const route = useRoute()
const sudoStore = useSudoStore()
Expand Down Expand Up @@ -94,6 +93,16 @@ function configureAxios() {
console.error('[interceptor]', error.response.data.message)
}

if (error.config) {
const ubusCommand = getUbusReproductionCommand(error.config)

if (ubusCommand) {
console.error('[interceptor] reproduce this call on the unit with:')
// logged on its own so it's easy to select and copy
console.error(ubusCommand)
}
}

if (error.response?.status == 401) {
if (isStandaloneMode()) {
if (error.response?.data?.message !== 'incorrect Username or Password') {
Expand Down Expand Up @@ -140,30 +149,6 @@ function configureAxios() {
}
}, 200)
})
} else {
// show error notification only if error is not caused from cancellation
// and if it isn't a validation error
// and if it isn't caused by one of the update endpoints because of the system rebooting
// and if it isn't caused by a net::ERR_CONNECTION_REFUSED when applying a migration (probably because of nginx restarting or the machine's ip addresses changing)
if (
!(error instanceof CanceledError) &&
!error.response?.data?.data?.validation?.errors?.length &&
!(
error.config.url.includes('/ubus/call') &&
(JSON.parse(error.config.data)?.method === 'install-uploaded-image' ||
JSON.parse(error.config.data)?.method === 'update-system') &&
// if the error is caused by a system reboot, the response will not have a payload (since it's caused by a net::ERR_CONNECTION_REFUSED)
(!error.response || !error.response.data)
) &&
!(
error.config.url.includes('/ubus/call') &&
(JSON.parse(error.config.data)?.path === 'ns.migration' ||
JSON.parse(error.config.data)?.method === 'upload') &&
(!error.response || !error.response.data)
)
) {
notificationsStore.createNotificationFromAxiosError(error)
}
}
return Promise.reject(error)
}
Expand Down
48 changes: 48 additions & 0 deletions src/lib/__tests__/axiosErrorCommand.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { getUbusReproductionCommand } from '@/lib/axiosErrorCommand'

describe('getUbusReproductionCommand', () => {
it('builds the rpcd command for a ns.* ubus call with a payload', () => {
const command = getUbusReproductionCommand({
url: 'https://unit.example/api/ubus/call',
data: JSON.stringify({ path: 'ns.dhcp', method: 'get-config', payload: { config: 'dhcp' } })
})

expect(command).toBe(`echo '{"config":"dhcp"}' | /usr/libexec/rpcd/ns.dhcp call get-config`)
})

it('omits the echo when the payload is empty', () => {
const command = getUbusReproductionCommand({
url: 'https://unit.example/api/ubus/call',
data: JSON.stringify({ path: 'ns.dhcp', method: 'get-config', payload: {} })
})

expect(command).toBe('/usr/libexec/rpcd/ns.dhcp call get-config')
})

it('returns undefined for a non-ubus request', () => {
const command = getUbusReproductionCommand({
url: 'https://unit.example/api/login',
data: JSON.stringify({ username: 'root', password: 'secret' })
})

expect(command).toBeUndefined()
})

it('returns undefined for a ubus call whose path is not ns.*', () => {
const command = getUbusReproductionCommand({
url: 'https://unit.example/api/ubus/call',
data: JSON.stringify({ path: 'uci', method: 'get', payload: { config: 'network' } })
})

expect(command).toBeUndefined()
})

it('returns undefined when config.data is missing', () => {
const command = getUbusReproductionCommand({
url: 'https://unit.example/api/ubus/call'
})

expect(command).toBeUndefined()
})
})
27 changes: 27 additions & 0 deletions src/lib/axiosErrorCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (C) 2024 Nethesis S.r.l.
// SPDX-License-Identifier: GPL-3.0-or-later

import { type AxiosRequestConfig } from 'axios'
import { isEmpty } from 'lodash-es'

// builds the on-device rpcd command to reproduce a failed ns.* ubus call (e.g. over SSH),
// returns undefined for anything else since the curl equivalent would require the auth token
export function getUbusReproductionCommand(config: AxiosRequestConfig): string | undefined {
if (!config.url?.includes('/ubus/call') || typeof config.data !== 'string') {
return undefined
}

const { path, method, payload } = JSON.parse(config.data)

if (!/^ns\..+/.test(path)) {
return undefined
}

let command = ''

if (!isEmpty(payload)) {
command += `echo '${JSON.stringify(payload)}' | `
}
command += `/usr/libexec/rpcd/${path} call ${method}`
return command
}
16 changes: 5 additions & 11 deletions src/stores/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { type NeNotification } from '@nethesis/vue-components'
import { useI18n } from 'vue-i18n'
import { useLoginStore as useControllerLoginStore } from '@/stores/controller/controllerLogin'
import { useLoginStore as useStandaloneLoginStore } from '@/stores/standalone/standaloneLogin'
import { isEmpty } from 'lodash-es'
import { isStandaloneMode } from '@/lib/config'
import { getUbusReproductionCommand } from '@/lib/axiosErrorCommand'

const NOTIFICATIONS_LIMIT = 30
const DEFAULT_NOTIFICATION_TIMEOUT = 5000
Expand Down Expand Up @@ -163,17 +163,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
}

const copyUbusApiCommandToClipboard = (notification: NeNotification) => {
const inputData = JSON.parse(notification.payload.config.data)
const ubusPath = inputData.path
const ubusMethod = inputData.method
const inputPayload = inputData.payload
let command = ``

if (!isEmpty(inputPayload)) {
command += `echo '${JSON.stringify(inputPayload)}' | `
const command = getUbusReproductionCommand(notification.payload.config)

if (command) {
navigator.clipboard.writeText(command)
}
command += `/usr/libexec/rpcd/${ubusPath} call ${ubusMethod}`
navigator.clipboard.writeText(command)
}

const showErrorDetails = (notification: NeNotification) => {
Expand Down
Loading