mirror of
https://github.com/ntop/ntopng.git
synced 2026-04-28 06:59:33 +00:00
Added progress bar component to config import modal (#10201)
* Added progress bar component to import modal * Update dist
This commit is contained in:
parent
81bdc213dc
commit
730a70a65e
4 changed files with 166 additions and 35 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -131,3 +131,4 @@ tests/e2e/rest/data/*
|
|||
tools/tmp.csv
|
||||
tests/e2e/rest/logs/*
|
||||
httpdocs/img/custom_logo.png
|
||||
.dual-graph/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
105
http_src/vue/upload-progress-bar.vue
Normal file
105
http_src/vue/upload-progress-bar.vue
Normal 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 0–100 */
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue