Added progress bar component to config import modal (#10201)

* Added progress bar component to import modal

* Update dist
This commit is contained in:
GabrieleDeri 2026-03-23 16:28:20 +01:00 committed by GitHub
parent 81bdc213dc
commit 730a70a65e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 166 additions and 35 deletions

1
.gitignore vendored
View file

@ -131,3 +131,4 @@ tests/e2e/rest/data/*
tools/tmp.csv
tests/e2e/rest/logs/*
httpdocs/img/custom_logo.png
.dual-graph/

View file

@ -60,6 +60,12 @@
accept=".json,.csv"
@change="on_file_selected"
/>
<UploadProgressBar
:visible="import_started"
:active="importing"
:progress="upload_progress"
:remaining_time="upload_remaining"
/>
<div v-if="import_error" class="alert alert-danger mt-2">{{ import_error }}</div>
</template>
<template v-slot:footer>
@ -98,12 +104,13 @@
<script setup>
import { ref, computed } from "vue";
import { default as modal } from "./modal.vue";
import { default as UploadProgressBar } from "./upload-progress-bar.vue";
const props = defineProps({ context: Object });
const _i18n = (key) => i18n(key);
// State
// State
const selected_key = ref(props.context.selected_item || "all");
const import_modal_ref = ref(null);
const reset_modal_ref = ref(null);
@ -112,10 +119,12 @@ const import_file_content = ref(null);
const import_file_name = ref("");
const import_error = ref("");
const importing = ref(false);
const import_started = ref(false);
const upload_progress = ref(0);
const upload_remaining = ref("");
const resetting = ref(false);
const exporting = ref(false);
// Computed
const sorted_items = computed(() =>
Object.values(props.context.configuration_items || {}).sort(
(a, b) => a.order - b.order
@ -177,11 +186,14 @@ const reset_modal_body = computed(() => {
);
});
// Import
// Import
function open_import_modal() {
import_file_content.value = null;
import_error.value = "";
import_file_name.value = "";
import_error.value = "";
import_started.value = false;
upload_progress.value = 0;
upload_remaining.value = "";
if (file_input_ref.value) file_input_ref.value.value = "";
import_modal_ref.value.show();
}
@ -192,8 +204,8 @@ function close_import_modal() {
function on_import_modal_shown() {
import_file_content.value = null;
import_error.value = "";
import_file_name.value = "";
import_error.value = "";
}
function on_file_selected(evt) {
@ -211,33 +223,57 @@ function on_file_selected(evt) {
async function do_import() {
if (!import_file_content.value) return;
importing.value = true;
import_started.value = true;
upload_progress.value = 0;
upload_remaining.value = "";
import_error.value = "";
const key = selected_key.value;
try {
let json_str = import_file_content.value;
// Strip nightly-backup payload to avoid huge uploads
try {
const conf = JSON.parse(json_str);
if (conf?.modules?.all?.["ntopng.prefs.config_save_backup"]) {
delete conf.modules.all["ntopng.prefs.config_save_backup"];
json_str = JSON.stringify(conf);
}
} catch (_) { /* CSV pool import — leave content as-is */ }
const isCsv = import_file_name.value.toLowerCase().endsWith(".csv");
const body = new URLSearchParams({
[isCsv ? "pool_CSV" : "JSON"]: json_str,
csrf: props.context.csrf
csrf: props.context.csrf,
});
const resp = await fetch(
`${http_prefix}/lua/rest/v2/import/${key}/config.lua`,
{ method: "POST", body }
);
const data = await resp.json();
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let prev_loaded = 0;
let prev_time = Date.now();
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const now = Date.now();
const dt = (now - prev_time) / 1000;
if (dt > 0) {
const bytes_per_sec = (e.loaded - prev_loaded) / dt;
const remaining_bytes = e.total - e.loaded;
if (bytes_per_sec > 0) {
const secs = Math.round(remaining_bytes / bytes_per_sec);
upload_remaining.value = secs < 60
? `${secs}s`
: secs < 3600
? `${Math.floor(secs / 60)}m ${secs % 60}s`
: `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
prev_loaded = e.loaded;
prev_time = now;
}
upload_progress.value = Math.round((e.loaded / e.total) * 100);
};
xhr.onload = () => {
upload_progress.value = 100;
upload_remaining.value = "";
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error("Invalid JSON response")); }
};
xhr.onerror = () => reject(new Error("Network error"));
xhr.open("POST", `${http_prefix}/lua/rest/v2/import/${key}/config.lua`);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send(body.toString());
});
if (data.rc < 0) {
import_error.value = data.rc_str || _i18n("invalid_file");
@ -252,7 +288,6 @@ async function do_import() {
delay: 2000,
});
close_import_modal();
} catch (err) {
import_error.value = _i18n("invalid_file");
@ -262,7 +297,7 @@ async function do_import() {
}
}
// Factory Reset
// Factory Reset
function open_reset_modal() {
reset_modal_ref.value.show();
}
@ -307,24 +342,17 @@ async function do_reset() {
}
}
// Export
// Export
/**
* Downloads the resource at the given URL as a file with the specified filename.
* Uses fetch instead of a plain <a href> to inherit the current authenticated
* browser session, which is required when ntopng is running over HTTPS/TLS.
*
* @param {string} url - The endpoint to fetch the file content from
* @param {string} filename - The filename to assign to the downloaded file
*/
async function downloadAsFile(url, filename) {
// Fetch the resource, sending cookies to authenticate the request
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error(`Export failed: ${response.status}`);
// Create a temporary <a> element pointing to an in-memory Blob URL,
// then programmatically click it to trigger the browser's file download.
// This is the only way to force a specific filename from JavaScript.
const a = Object.assign(document.createElement("a"), {
href: URL.createObjectURL(await response.blob()),
download: filename,
@ -332,8 +360,6 @@ async function downloadAsFile(url, filename) {
document.body.appendChild(a);
a.click();
a.remove();
// Release the Blob URL from memory once the download has been triggered
URL.revokeObjectURL(a.href);
}
@ -349,5 +375,4 @@ async function on_export_click() {
.catch((err) => console.error("Export error:", err))
.finally(() => { exporting.value = false; });
}
</script>

View file

@ -0,0 +1,105 @@
<!-- (C) 2024 - ntop.org -->
<template>
<Transition name="ntop-uprogress-fade">
<div v-if="visible" class="ntop-uprogress">
<!-- Track + percentage + remaining time row -->
<div class="ntop-uprogress__row">
<div class="ntop-uprogress__track">
<div
class="ntop-uprogress__bar"
role="progressbar"
:style="{ width: `${clampedProgress}%` }"
:aria-valuenow="clampedProgress"
aria-valuemin="0"
aria-valuemax="100"
></div>
</div>
<span class="ntop-uprogress__pct">{{ clampedProgress }}%</span>
<span v-if="active && remaining_time" class="ntop-uprogress__remaining">
<i class="fas fa-clock"></i> {{ remaining_time }}
</span>
</div>
</div>
</Transition>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
/** Upload progress value 0100 */
progress: { type: Number, default: 0 },
/** Whether the progress bar is rendered */
visible: { type: Boolean, default: false },
/** Whether an upload is currently in progress (enables shimmer) */
active: { type: Boolean, default: false },
/** Formatted remaining time string, e.g. "12s" */
remaining_time: { type: String, default: "" },
});
const clampedProgress = computed(() =>
Math.min(100, Math.max(0, Math.round(props.progress)))
);
</script>
<style scoped>
.ntop-uprogress {
margin-top: 0.75rem;
}
/* Track + percentage row */
.ntop-uprogress__row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.ntop-uprogress__track {
flex: 1;
height: 8px;
background-color: var(--bg-sunken, #f1f3f5);
border: 1px solid var(--border-subtle, #e9ecef);
border-radius: 100px;
overflow: hidden;
}
.ntop-uprogress__bar {
height: 100%;
border-radius: 100px;
background-color: var(--ntop-orange, #ff8f00);
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.ntop-uprogress__pct,
.ntop-uprogress__remaining {
width: 3.5rem;
text-align: right;
font-size: 0.78rem;
font-weight: 600;
color: var(--ntop-text-color, #111111);
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
/* Transitions */
.ntop-uprogress-fade-enter-active,
.ntop-uprogress-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.ntop-uprogress-fade-enter-from,
.ntop-uprogress-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.ntop-uprogress-meta-fade-enter-active,
.ntop-uprogress-meta-fade-leave-active {
transition: opacity 0.15s ease;
}
.ntop-uprogress-meta-fade-enter-from,
.ntop-uprogress-meta-fade-leave-to {
opacity: 0;
}
</style>

@ -1 +1 @@
Subproject commit a360c294be3968c7b0bf8b74197f09080dba66e4
Subproject commit 3ea1bb6b3cd5ea5024c3815a0f1eb21ececf1655