This is an automated email from the ASF dual-hosted git repository.
afs pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git
The following commit(s) were added to refs/heads/main by this push:
new 578ae5ed2f GH-2162: Add more detailed logging to upload errors.
578ae5ed2f is described below
commit 578ae5ed2f76ee4ab129f3f9d6b212dc7c1414ea
Author: Thomas Thelen <[email protected]>
AuthorDate: Tue Jul 7 09:09:48 2026 -0700
GH-2162: Add more detailed logging to upload errors.
---
.../jena-fuseki-ui/src/views/dataset/Upload.vue | 84 +++++++++++----
.../tests/unit/views/dataset/upload.vue.spec.js | 118 +++++++++++++++++++++
2 files changed, 183 insertions(+), 19 deletions(-)
diff --git a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
index 95adc97cf8..052b348661 100644
--- a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
+++ b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Upload.vue
@@ -182,6 +182,9 @@
{{ data.item.response.tripleCount }}
</span>
<span v-else class="small">0</span>
+ <div v-if="data.item.error" class="small text-danger
upload-error-message">
+ {{ getUploadErrorMessage(data.item) }}
+ </div>
</template>
<template #cell(actions)="data">
<button
@@ -368,7 +371,7 @@ export default {
},
upload: {
handler () {
- this.validateFiles()
+ this.validateFiles(false)
},
deep: true,
immediate: false
@@ -425,29 +428,72 @@ export default {
this.graphNameClasses = ['form-control', formValidationClass]
return isValidGraphName
},
- validateFiles () {
+ /**
+ * Validate the selected upload files and update fileUploadClasses to
reflect the result.
+ *
+ * @param {boolean} explicit - Whether this call comes from an explicit
user action such as a
+ * submit attempt. When true, an empty file list is flagged invalid.
When false (e.g. a passive
+ * change like removing a file), an empty list is only flagged invalid
if it already was.
+ * @return {boolean} - true if at least one file is selected.
+ */
+ validateFiles (explicit = true) {
if (this.upload.files !== null && this.upload.files.length > 0) {
- this.fileUploadClasses = [
- 'btn',
- 'btn-success',
- 'is-valid'
- ]
- return true
+ this.fileUploadClasses = ['btn', 'btn-success', 'is-valid']
+ } else if (explicit || this.fileUploadClasses.includes('is-invalid')) {
+ this.fileUploadClasses = ['btn', 'btn-success', 'is-invalid']
+ } else {
+ this.fileUploadClasses = ['btn', 'btn-success']
}
- this.fileUploadClasses = [
- 'btn',
- 'btn-success',
- 'is-invalid'
- ]
- return false
+ return this.fileUploadClasses.includes('is-valid')
},
+ /**
+ * Upload a single file via the vue-upload-component instance, surfacing
any failure
+ * as a displayed error instead of an unhandled rejection.
+ *
+ * @param {object} file - The vue-upload-component file entry being
uploaded.
+ * @param {object} component - The vue-upload-component instance (as
passed to custom-action).
+ * @return {Promise<*>} - The resolved response from uploadHtml5, or
undefined on failure.
+ */
async handleUploadWithErrorHandling (file, component) {
try {
- return component
- .uploadHtml5(file)
- .catch(error => displayError(this, error))
- } catch (error) {
- displayError(this, error)
+ return await component.uploadHtml5(file)
+ } catch {
+ const uploadedFile = component.get(file) || file
+ displayError(this, this.getUploadErrorMessage(uploadedFile))
+ }
+ },
+ /**
+ * Build a human-readable error message for a failed upload file.
+ *
+ * Prefers the server's detailed error response body (plain text or a JSON
object with
+ * a "message" field) over the generic vue-upload-component file.error
code, falling
+ * back to a readable message per error code when no response body is
available.
+ *
+ * @param {object} file - The vue-upload-component file entry, expected to
carry
+ * `error` (a vue-upload-component error code) and/or `response` (the
server's body).
+ * @return {string} - A human-readable error message.
+ */
+ getUploadErrorMessage (file) {
+ const response = file && file.response
+ if (typeof response === 'string' && response.trim() !== '') {
+ return response.trim()
+ }
+ if (response && typeof response === 'object' && response.message) {
+ return response.message
+ }
+ switch (file && file.error) {
+ case 'network':
+ return 'Upload failed: could not reach the server. Please check your
connection and try again.'
+ case 'timeout':
+ return 'Upload failed: the request timed out.'
+ case 'abort':
+ return 'Upload was cancelled.'
+ case 'denied':
+ return 'Upload was rejected by the server.'
+ case 'server':
+ return 'Upload failed: the server encountered an error while
processing the file.'
+ default:
+ return 'Upload failed.'
}
}
}
diff --git
a/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/upload.vue.spec.js
b/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/upload.vue.spec.js
new file mode 100644
index 0000000000..4c06ec7667
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/upload.vue.spec.js
@@ -0,0 +1,118 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import Upload from '@/views/dataset/Upload.vue'
+import { describe, it, expect, vi } from 'vitest'
+
+const mountFunction = (options) => {
+ return mount(Upload, Object.assign(options || {}, {
+ shallow: true,
+ props: {
+ datasetName: 'test-ds'
+ },
+ global: {
+ mocks: {
+ $route: { params: { datasetName: 'test-ds' } },
+ $fusekiService: {
+ getFusekiUrl: (path) => `http://localhost:3030${path}`,
+ getDatasetServices: vi.fn().mockResolvedValue({ 'gsp-rw': {
'srv.endpoints': ['data'] } })
+ }
+ }
+ }
+ }))
+}
+
+describe('Upload.vue', () => {
+ describe('getFileStatus', () => {
+ it('returns "danger" for a file with an error', () => {
+ const wrapper = mountFunction()
+ expect(wrapper.vm.getFileStatus({ error: 'some error', success: false,
active: false })).toBe('danger')
+ })
+
+ it('returns "success" for a successfully uploaded file', () => {
+ const wrapper = mountFunction()
+ expect(wrapper.vm.getFileStatus({ error: null, success: true, active:
false })).toBe('success')
+ })
+
+ it('returns "primary" for an active upload', () => {
+ const wrapper = mountFunction()
+ expect(wrapper.vm.getFileStatus({ error: null, success: false, active:
true })).toBe('primary')
+ })
+
+ it('returns empty string for a file with no status', () => {
+ const wrapper = mountFunction()
+ expect(wrapper.vm.getFileStatus({ error: null, success: false, active:
false })).toBe('')
+ })
+ })
+
+ describe('getUploadErrorMessage', () => {
+ it('returns the server\'s detailed plain-text error response, when
present', () => {
+ const wrapper = mountFunction()
+ const file = { error: 'denied', response: ' Parse Error: [line: 3, col:
5] Triples not terminated by \'.\' ' }
+ expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Parse Error: [line:
3, col: 5] Triples not terminated by \'.\'')
+ })
+
+ it('returns the message field from a JSON error response, when present',
() => {
+ const wrapper = mountFunction()
+ const file = { error: 'denied', response: { message: 'Unsupported Media
Type' } }
+ expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Unsupported Media
Type')
+ })
+
+ it('falls back to a readable message for a generic "server" error code',
() => {
+ const wrapper = mountFunction()
+ const file = { error: 'server', response: {} }
+ expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Upload failed: the
server encountered an error while processing the file.')
+ })
+
+ it('falls back to a readable message for a generic "network" error code',
() => {
+ const wrapper = mountFunction()
+ const file = { error: 'network', response: {} }
+ expect(wrapper.vm.getUploadErrorMessage(file)).toBe('Upload failed:
could not reach the server. Please check your connection and try again.')
+ })
+
+ it('returns a generic message when there is no file', () => {
+ const wrapper = mountFunction()
+ expect(wrapper.vm.getUploadErrorMessage(null)).toBe('Upload failed.')
+ })
+ })
+
+ describe('file list validation', () => {
+ it('does not flag the form invalid just from removing the last (failed)
file', async () => {
+ const wrapper = mountFunction()
+ await nextTick()
+
+ wrapper.vm.upload.files = [{ id: '1', name: 'broken.ttl', error: null,
success: false, active: false, response: {} }]
+ await nextTick()
+ expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success',
'is-valid'])
+
+ wrapper.vm.upload.files = [{ id: '1', name: 'broken.ttl', error:
'denied', success: false, active: false, response: 'Parse Error' }]
+ await nextTick()
+ expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success',
'is-valid'])
+
+ wrapper.vm.upload.files = []
+ await nextTick()
+ expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success'])
+ })
+
+ it('still flags the form invalid on an explicit submit attempt with no
files', () => {
+ const wrapper = mountFunction()
+ expect(wrapper.vm.validateFiles()).toBe(false)
+ expect(wrapper.vm.fileUploadClasses).toEqual(['btn', 'btn-success',
'is-invalid'])
+ })
+ })
+})