mirror of
https://github.com/ntop/ntopng.git
synced 2026-04-28 06:59:33 +00:00
Added ai stats page, updated breakdown, 3x speedup dist, removed deps on npm (#10236)
* Added ai stats page, updated breakdown style, 3x speedup npm run build, removed deps on npm * Added ai stats page href * Fixes ai stats page * Update dist * Fixes create_dist
This commit is contained in:
parent
dea00e2082
commit
6801ec1034
37 changed files with 1667 additions and 381 deletions
40
build.mjs
40
build.mjs
|
|
@ -31,16 +31,11 @@ import sharp from 'sharp';
|
|||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const isProd = process.argv.includes('--prod');
|
||||
|
||||
const terserOptions = {
|
||||
compress: { drop_console: true },
|
||||
output: { ecma: 5 },
|
||||
};
|
||||
|
||||
/** Shared SCSS / PostCSS options */
|
||||
const sharedCSS = {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'mixed-decls'],
|
||||
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'if-function'],
|
||||
}
|
||||
},
|
||||
postcss: {
|
||||
|
|
@ -91,7 +86,7 @@ const imageminPlugin = isProd ? {
|
|||
|
||||
/** Asset output path rules */
|
||||
const assetFileNames = (assetInfo) => {
|
||||
const name = assetInfo.name || '';
|
||||
const name = assetInfo.names?.[0] || '';
|
||||
if (/\.(png|gif|svg|jpg|jpeg|ico)$/i.test(name)) return 'images/[name][extname]';
|
||||
if (/\.(woff2?|ttf|eot|otf)$/i.test(name)) return 'assets/[name][extname]';
|
||||
return '[name][extname]';
|
||||
|
|
@ -107,13 +102,29 @@ const handleEvalFiles = {
|
|||
}
|
||||
};
|
||||
|
||||
/* Suppress eval warnings from third-party files as we cannot fix them */
|
||||
const onwarnSuppressEval = (warning, defaultHandler) => {
|
||||
if (warning.code === 'EVAL' && warning.id &&
|
||||
(warning.id.includes('store-js/plugins/lib/json2.js') ||
|
||||
warning.id.includes('jquery.tablesorter.js'))) {
|
||||
return;
|
||||
}
|
||||
defaultHandler(warning);
|
||||
};
|
||||
|
||||
/**
|
||||
* The inject plugin adds `import $ from 'jquery'` (and jQuery / moment)
|
||||
* to any module that uses those names as free variables without importing them.
|
||||
* This covers legacy vendor scripts (bootstrap-datatable, bootstrap-select,
|
||||
* jquery.tablesorter, etc.) that assume jQuery is available as a global.
|
||||
*/
|
||||
const injectGlobals = inject({ $: 'jquery', jQuery: 'jquery', moment: 'moment-timezone' });
|
||||
const injectGlobals = inject({
|
||||
$: 'jquery',
|
||||
jQuery: 'jquery',
|
||||
moment: 'moment-timezone',
|
||||
include: ['**/*.js', '**/*.ts', '**/*.vue', '**/*.mjs'],
|
||||
exclude: ['**/*.css', '**/*.scss', '**/*.sass'],
|
||||
});
|
||||
|
||||
// Build 1: third-party.js
|
||||
// Self-contained IIFE — bundles jQuery, Bootstrap, DataTables, Leaflet, etc.
|
||||
|
|
@ -131,11 +142,11 @@ await build({
|
|||
emptyOutDir: true, // wipe dist only on the first build step
|
||||
cssCodeSplit: false, // extract CSS to a file (not inline via __vite_style__)
|
||||
sourcemap: !isProd,
|
||||
minify: isProd ? 'terser' : false,
|
||||
terserOptions: isProd ? terserOptions : undefined,
|
||||
minify: isProd ? 'esbuild' : false,
|
||||
chunkSizeWarningLimit: 5000,
|
||||
rollupOptions: {
|
||||
context: 'window',
|
||||
onwarn: onwarnSuppressEval,
|
||||
input: { 'third-party': resolve(__dirname, 'assets/third-party.js') },
|
||||
output: {
|
||||
format: 'iife',
|
||||
|
|
@ -163,10 +174,10 @@ await build({
|
|||
emptyOutDir: false,
|
||||
cssCodeSplit: false, // extract CSS to a file (not inline via __vite_style__)
|
||||
sourcemap: !isProd,
|
||||
minify: isProd ? 'terser' : false,
|
||||
terserOptions: isProd ? terserOptions : undefined,
|
||||
minify: isProd ? 'esbuild' : false,
|
||||
chunkSizeWarningLimit: 5000,
|
||||
rollupOptions: {
|
||||
onwarn: onwarnSuppressEval,
|
||||
input: { ntopng: resolve(__dirname, 'http_src/ntopng.js') },
|
||||
external: ['jquery', 'moment', 'moment-timezone'],
|
||||
output: {
|
||||
|
|
@ -205,7 +216,7 @@ for (const { entry, name } of cssEntries) {
|
|||
outDir: 'httpdocs/dist',
|
||||
emptyOutDir: false,
|
||||
cssCodeSplit: false, // extract CSS to a file (not inline via __vite_style__)
|
||||
minify: isProd ? 'terser' : false,
|
||||
minify: isProd ? 'esbuild' : false,
|
||||
rollupOptions: {
|
||||
input: { [name]: resolve(__dirname, entry) },
|
||||
output: {
|
||||
|
|
@ -249,8 +260,7 @@ await build({
|
|||
build: {
|
||||
outDir: 'httpdocs/dist',
|
||||
emptyOutDir: false,
|
||||
minify: isProd ? 'terser' : false,
|
||||
terserOptions: isProd ? terserOptions : undefined,
|
||||
minify: isProd ? 'esbuild' : false,
|
||||
rollupOptions: {
|
||||
input: { login: resolve(__dirname, 'assets/scripts/login.js') },
|
||||
output: {
|
||||
|
|
|
|||
|
|
@ -1,35 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
#
|
||||
# In case you have never used npm do
|
||||
#
|
||||
# npm install
|
||||
#
|
||||
|
||||
CURR_DIR=$(pwd)
|
||||
|
||||
branch_name=`git branch | head | cut -d ' ' -f 2 | tail -n 1`
|
||||
|
||||
echo "-- Cleaning up dist -- "
|
||||
branch_name=$(git branch --show-current)
|
||||
echo "Dist Branch: $branch_name"
|
||||
echo "-- Cleaning up dist --"
|
||||
cd httpdocs/dist
|
||||
git fetch
|
||||
git checkout $branch_name
|
||||
git reset --hard @{u}
|
||||
git checkout -B "$branch_name" "origin/$branch_name"
|
||||
|
||||
echo "-- Compiling dist -- "
|
||||
cd $CURR_DIR
|
||||
echo "-- Compiling dist --"
|
||||
cd "$CURR_DIR"
|
||||
npm run build || exit 1
|
||||
|
||||
echo "-- Pushing dist --"
|
||||
cd httpdocs/dist
|
||||
git add *
|
||||
git add -A
|
||||
git commit -m 'Update dist' || exit 1
|
||||
git push || exit 1
|
||||
git push origin "$branch_name" || exit 1
|
||||
|
||||
echo "-- Pushing ref --"
|
||||
cd $CURR_DIR
|
||||
cd "$CURR_DIR"
|
||||
git add httpdocs/dist
|
||||
git commit -m 'Update dist' || exit 1
|
||||
git push || exit 1
|
||||
|
||||
echo "Dist up to date"
|
||||
|
|
|
|||
|
|
@ -1329,38 +1329,43 @@ export default class NtopUtils {
|
|||
}
|
||||
|
||||
static createProgressBar(percentage) {
|
||||
return `<div class="d-flex flex-row align-items-center">
|
||||
<div class="col-9 progress">
|
||||
<div class="progress-bar bg-warning" aria-valuenow="${percentage}" aria-valuemin="0" aria-valuemax="100" style="width: ${percentage}%;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col"> ${percentage} %</div>
|
||||
</div>`
|
||||
const pct = Math.min(100, Math.max(0, Math.floor(percentage)));
|
||||
const color = pct >= 80 ? 'var(--ntop-orange, #FF8F00)' : pct >= 50 ? '#f59e0b' : 'var(--ntop-blue, #37474F)';
|
||||
return `<div class="d-flex align-items-center gap-2" style="min-width:0">
|
||||
<div style="flex:1;height: 8px;background:var(--border-color,#dee2e6);border-radius:100px;overflow:hidden;">
|
||||
<div style="width:${pct}%;height:100%;background:${color};border-radius:100px;transition:width .3s ease;"></div>
|
||||
</div>
|
||||
<span style="font-size:0.75rem;font-weight:600;color:var(--ntop-muted-text-color,#37474F);white-space:nowrap;min-width:2.5rem;text-align:right;">${pct}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static createBreakdown(percentage_1, percentage_2, label_1, label_2) {
|
||||
if (percentage_1 == 0 && percentage_2 == 0) {
|
||||
return `<div class="d-flex flex-row align-items-center progress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="No data available"></div>
|
||||
</div>`
|
||||
return `<div style="height:8px;background:var(--border-subtle,#e9ecef);border-radius:100px;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="No data available"></div>`;
|
||||
}
|
||||
|
||||
let progressBars = '';
|
||||
|
||||
let bars = '';
|
||||
if (percentage_1 > 0) {
|
||||
progressBars += `<div class="progress-bar bg-warning" aria-valuenow="${percentage_1}" aria-valuemin="0" aria-valuemax="100"
|
||||
style="width: ${percentage_1}%;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="${label_1}: ${Math.floor(percentage_1)}%">${label_1}</div>`;
|
||||
bars += `<div style="width:${percentage_1}%;height:100%;background:var(--ntop-orange,#FF8F00);border-radius:${percentage_2 > 0 ? '100px 0 0 100px' : '100px'};transition:width .3s ease;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="${label_1}: ${Math.floor(percentage_1)}%"></div>`;
|
||||
}
|
||||
|
||||
if (percentage_2 > 0) {
|
||||
progressBars += `<div class="progress-bar bg-success" aria-valuenow="${percentage_2}" aria-valuemin="0" aria-valuemax="100"
|
||||
style="width: ${percentage_2}%;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="${label_2}: ${Math.floor(percentage_2)}%">${label_2}</div>`;
|
||||
bars += `<div style="width:${percentage_2}%;height:100%;background:#0d9488;border-radius:${percentage_1 > 0 ? '0 100px 100px 0' : '100px'};transition:width .3s ease;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="${label_2}: ${Math.floor(percentage_2)}%"></div>`;
|
||||
}
|
||||
|
||||
return `<div class="d-flex flex-row align-items-center progress">${progressBars}</div>`
|
||||
const legend_1 = percentage_1 > 0
|
||||
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.7rem;color:var(--ntop-muted-text-color,#37474F);">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:var(--ntop-orange,#FF8F00);flex-shrink:0;"></span>${label_1} ${Math.floor(percentage_1)}%</span>` : '';
|
||||
const legend_2 = percentage_2 > 0
|
||||
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.7rem;color:var(--ntop-muted-text-color,#37474F);">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#0d9488;flex-shrink:0;"></span>${label_2} ${Math.floor(percentage_2)}%</span>` : '';
|
||||
|
||||
return `<div style="display:flex;flex-direction:column;gap:3px;min-width:0;">
|
||||
<div style="height:8px;background:var(--border-color,#dee2e6);border-radius:100px;overflow:hidden;display:flex;">${bars}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">${legend_1}${legend_2}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Return the number of rows available in a table */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
:root {
|
||||
font-weight: 500;
|
||||
--sidebar-width: 4.5rem;
|
||||
--footer-height: 4rem;
|
||||
--padding-md-four: 1.5rem;
|
||||
|
|
@ -879,6 +880,7 @@ li>a>.fa-external-link-alt {
|
|||
box-shadow: 0px 1px 22px -12px #607D8B;
|
||||
background-color: #FFFFFF;
|
||||
padding: 25px 35px 20px 30px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.widget-box-fix {
|
||||
|
|
@ -1354,4 +1356,153 @@ a.disabled {
|
|||
|
||||
.dropdown-item.disabled {
|
||||
color: var(--ntop-disabled-text-color) !important;
|
||||
}
|
||||
|
||||
/* Flatpickr calendar theme */
|
||||
.flatpickr-calendar {
|
||||
background: var(--bg-surface) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
|
||||
font-size: 0.8rem !important;
|
||||
color: var(--ntop-text-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-calendar.arrowTop::before,
|
||||
.flatpickr-calendar.arrowTop::after {
|
||||
border-bottom-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-calendar.arrowBottom::before,
|
||||
.flatpickr-calendar.arrowBottom::after {
|
||||
border-top-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-months {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
padding: 0.1rem 0 !important;
|
||||
}
|
||||
|
||||
.flatpickr-months .flatpickr-month,
|
||||
.flatpickr-months .flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month {
|
||||
color: var(--ntop-text-color) !important;
|
||||
fill: var(--ntop-text-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-months .flatpickr-prev-month:hover svg,
|
||||
.flatpickr-months .flatpickr-next-month:hover svg {
|
||||
fill: var(--ntop-orange) !important;
|
||||
}
|
||||
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months,
|
||||
.flatpickr-current-month input.cur-year {
|
||||
background: transparent !important;
|
||||
color: var(--ntop-text-color) !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.flatpickr-weekdays {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
span.flatpickr-weekday {
|
||||
background: transparent !important;
|
||||
color: var(--ntop-muted-text-color) !important;
|
||||
font-size: 0.72rem !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
.flatpickr-day {
|
||||
color: var(--ntop-text-color) !important;
|
||||
border-radius: 5px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.flatpickr-day:hover,
|
||||
.flatpickr-day.prevMonthDay:hover,
|
||||
.flatpickr-day.nextMonthDay:hover {
|
||||
background: var(--bg-sunken) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange {
|
||||
background: var(--ntop-blue) !important;
|
||||
border-color: var(--ntop-blue) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.startRange:hover,
|
||||
.flatpickr-day.endRange:hover {
|
||||
background: var(--ntop-blue-dark) !important;
|
||||
border-color: var(--ntop-blue-dark) !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.today {
|
||||
border-color: var(--ntop-orange) !important;
|
||||
color: var(--ntop-orange) !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.today:hover {
|
||||
background: rgba(255, 143, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay {
|
||||
color: var(--ntop-muted-text-color) !important;
|
||||
opacity: 0.45 !important;
|
||||
}
|
||||
|
||||
.flatpickr-day.disabled,
|
||||
.flatpickr-day.disabled:hover {
|
||||
color: var(--ntop-disabled-text-color) !important;
|
||||
}
|
||||
|
||||
/* ── Flatpickr time picker ── */
|
||||
.flatpickr-time {
|
||||
background: var(--bg-surface) !important;
|
||||
border-top: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 0 0 8px 8px !important;
|
||||
}
|
||||
|
||||
.flatpickr-time input.flatpickr-hour,
|
||||
.flatpickr-time input.flatpickr-minute,
|
||||
.flatpickr-time input.flatpickr-second {
|
||||
background: transparent !important;
|
||||
color: var(--ntop-text-color) !important;
|
||||
font-size: 0.85rem !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.flatpickr-time input:hover,
|
||||
.flatpickr-time input:focus {
|
||||
background: var(--bg-sunken) !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .flatpickr-time-separator,
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
color: var(--ntop-muted-text-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: var(--ntop-muted-text-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-time .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: var(--ntop-muted-text-color) !important;
|
||||
}
|
||||
|
||||
.flatpickr-innerContainer {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.flatpickr-rContainer {
|
||||
padding: 0.25rem 0 !important;
|
||||
}
|
||||
|
|
@ -106,6 +106,15 @@
|
|||
<i class="fas fa-gear"></i>
|
||||
</a>
|
||||
|
||||
<!-- Usage stats link -->
|
||||
<a
|
||||
class="sidebar-toggle-btn flex-shrink-0"
|
||||
:href="statsUrl"
|
||||
title="LLM Usage Stats"
|
||||
>
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
</a>
|
||||
|
||||
<!-- Provider / model selector -->
|
||||
<div v-if="loadingProviders" class="d-flex align-items-center gap-2 small chat-muted-text ms-1">
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
|
|
@ -461,6 +470,7 @@ const providerDropdownOpen = ref(false);
|
|||
const providerSelectorRef = ref(null);
|
||||
|
||||
const settingsUrl = ref(`${http_prefix}/lua/admin/prefs.lua?tab=llm_providers`);
|
||||
const statsUrl = ref(`${http_prefix}/lua/pro/ai_stats.lua`);
|
||||
|
||||
const MAX_HISTORY = 40;
|
||||
const timeoutSec = 120;
|
||||
|
|
@ -499,7 +509,7 @@ function getProviderIcon(provider) {
|
|||
// Add messagem when it arrives or user writes a new message
|
||||
function pushMessage(role, content, error = false, stats = null, artifact = null, queries = null) {
|
||||
messages.value.push({ role, content, time: nowTime(), error, stats, artifact, queries });
|
||||
nextTick(scrollBottom);
|
||||
nextTick(role === 'assistant' ? scrollToLastMessage : scrollBottom);
|
||||
}
|
||||
|
||||
// scroll view to bottom
|
||||
|
|
@ -509,6 +519,15 @@ function scrollBottom() {
|
|||
}
|
||||
}
|
||||
|
||||
function scrollToLastMessage() {
|
||||
if (!messageList.value) return;
|
||||
const bubbles = messageList.value.querySelectorAll('.chat-bubble');
|
||||
const last = bubbles[bubbles.length - 1];
|
||||
if (last) {
|
||||
last.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize(e) {
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
|
|
@ -767,12 +786,12 @@ async function send() {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
}, /* throw_exception */ true);
|
||||
}, /* throw_exception */ true, /* not_unwrap */ false, /* return_error */ true);
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
const reply = rsp?.reply ?? null;
|
||||
if (!reply) throw new Error(_i18n("llm.empty_response_error"));
|
||||
if (!reply) throw new Error(rsp?.error_message ?? _i18n("llm.generic_error"));
|
||||
|
||||
// Append assistant message to history so next request includes it
|
||||
history.value.push({ role: "assistant", content: reply });
|
||||
|
|
@ -788,7 +807,7 @@ async function send() {
|
|||
if (err.name === "AbortError") {
|
||||
pushMessage("assistant", _i18n("llm.timeout_error_message"), true);
|
||||
} else {
|
||||
pushMessage("assistant", `${_i18n("llm.request_error")}: ${err.message}`, true);
|
||||
pushMessage("assistant", err.message || _i18n("llm.generic_error"), true);
|
||||
}
|
||||
} finally {
|
||||
sending.value = false;
|
||||
|
|
@ -798,9 +817,21 @@ async function send() {
|
|||
|
||||
// On component mount load providers and chat history
|
||||
onMounted(() => {
|
||||
// if a chatId is selected in the url, pass it to retrieve the selected chat
|
||||
const selected_chatId = ntopng_url_manager.get_url_entry("chatId");
|
||||
|
||||
if (selected_chatId) {
|
||||
chat_UUID.value = selected_chatId;
|
||||
loadChat(chat_UUID.value);
|
||||
} else {
|
||||
// at page load always leave historical chat sidebar open if no chat ID is selected
|
||||
sidebarOpen.value = true;
|
||||
}
|
||||
|
||||
loadProviders();
|
||||
loadChatHistory();
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ async function get_chart_data() {
|
|||
}
|
||||
|
||||
function drawChart(data) {
|
||||
debugger;
|
||||
const container = chartContainer.value;
|
||||
|
||||
// Check that container exists before proceeding
|
||||
|
|
@ -358,7 +357,6 @@ async function refresh_chart() {
|
|||
isLoading.value = (props?.showOnlyFirstLoading === true) ? (firstLoading.value && true) : true;
|
||||
|
||||
const data = await get_chart_data();
|
||||
debugger;
|
||||
if (!data) {
|
||||
chart_data_available.value = false;
|
||||
isLoading.value = false
|
||||
|
|
|
|||
|
|
@ -1,53 +1,54 @@
|
|||
<!-- (C) 2022 - ntop.org -->
|
||||
<template>
|
||||
<div class="input-group">
|
||||
<div class="form-group">
|
||||
<div class="controls">
|
||||
<div class="btn-group me-auto btn-group-sm flex-wrap d-flex">
|
||||
<slot name="begin"></slot>
|
||||
<div>
|
||||
<select-search :disabled="disabled_date_picker" v-model:selected_option="selected_time_option"
|
||||
:id="'time_preset_range_picker'" :options="time_preset_list_filtered"
|
||||
@select_option="change_select_time(null)">
|
||||
</select-search>
|
||||
</div>
|
||||
<div class="btn-group ms-2">
|
||||
<input :disabled="disabled_date_picker" class="flatpickr flatpickr-input form-control"
|
||||
type="text" placeholder="Choose a date.." data-id="datetime" ref="begin-date"
|
||||
style="width:10rem;">
|
||||
<!-- <input ref="begin-date" @change="enable_apply=true" @change="change_begin_date" type="date" class="date_time_input begin-timepicker form-control border-right-0 fix-safari-input"> -->
|
||||
<!-- <input ref="begin-time" @change="enable_apply=true" type="time" class="date_time_input begin-timepicker form-control border-right-0 fix-safari-input"> -->
|
||||
<span class="input-group-text">
|
||||
<i class="fas fa-long-arrow-alt-right"></i>
|
||||
</span>
|
||||
<input :disabled="disabled_date_picker" class="flatpickr flatpickr-input form-control"
|
||||
type="text" placeholder="Choose a date.." data-id="datetime" ref="end-date"
|
||||
style="width:10rem;">
|
||||
<!-- <input ref="end-date" @change="enable_apply=true" type="date" class="date_time_input end-timepicker form-control border-left-0 fix-safari-input" style="width: 2.5rem;"> -->
|
||||
<!-- <input ref="end-time" @change="enable_apply=true" type="time" class="date_time_input end-timepicker form-control border-left-0 fix-safari-input"> -->
|
||||
<span v-show="wrong_date || wrong_min_interval" :title="invalid_date_message"
|
||||
style="margin-left:0.2rem;color:red;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dtrp-bar d-flex align-items-center flex-wrap gap-2">
|
||||
<slot name="begin"></slot>
|
||||
|
||||
<div class="d-flex align-items-center ms-2">
|
||||
<button :disabled="!enable_apply || wrong_date || wrong_min_interval" @click="apply"
|
||||
type="button" class="btn btn-sm btn-primary">{{
|
||||
i18n('apply') }}</button>
|
||||
<!-- Time preset selector -->
|
||||
<div class="dtrp-preset">
|
||||
<select-search
|
||||
:disabled="disabled_date_picker"
|
||||
v-model:selected_option="selected_time_option"
|
||||
:id="'time_preset_range_picker'"
|
||||
:options="time_preset_list_filtered"
|
||||
@select_option="change_select_time(null)"
|
||||
dropdown_size="small">
|
||||
</select-search>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button :disabled="select_time_value == 'custom' || disabled_date_picker"
|
||||
@click="change_select_time()" type="button" class="btn btn-sm btn-link"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
:title="i18n('date_time_range_picker.btn_refresh')">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<slot name="extra_buttons"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Date range inputs -->
|
||||
<div class="dtrp-range d-flex align-items-center gap-1">
|
||||
<input :disabled="disabled_date_picker"
|
||||
class="dtrp-input flatpickr flatpickr-input form-control form-control-sm"
|
||||
type="text" placeholder="Begin date.."
|
||||
data-id="datetime" ref="begin-date">
|
||||
<span class="dtrp-arrow"><i class="fas fa-arrow-right"></i></span>
|
||||
<input :disabled="disabled_date_picker"
|
||||
class="dtrp-input flatpickr flatpickr-input form-control form-control-sm"
|
||||
type="text" placeholder="End date.."
|
||||
data-id="datetime" ref="end-date">
|
||||
<span v-show="wrong_date || wrong_min_interval"
|
||||
:title="invalid_date_message" class="dtrp-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button
|
||||
:disabled="!enable_apply || wrong_date || wrong_min_interval"
|
||||
@click="apply" type="button"
|
||||
class="dtrp-btn dtrp-btn-primary">
|
||||
{{ i18n('apply') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="select_time_value == 'custom' || disabled_date_picker"
|
||||
@click="change_select_time()" type="button"
|
||||
class="dtrp-btn dtrp-btn-icon"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
:title="i18n('date_time_range_picker.btn_refresh')">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<slot name="extra_buttons"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -252,19 +253,6 @@ export default {
|
|||
});
|
||||
},
|
||||
apply: function () {
|
||||
// let date_begin = this.$refs["begin-date"].valueAsDate;
|
||||
// let d_time_begin = this.$refs["begin-time"].valueAsDate;
|
||||
// date_begin.setHours(d_time_begin.getHours());
|
||||
// date_begin.setMinutes(d_time_begin.getMinutes() + d_time_begin.getTimezoneOffset());
|
||||
// date_begin.setSeconds(d_time_begin.getSeconds());
|
||||
|
||||
// let date_end = this.$refs["end-date"].valueAsDate;
|
||||
// let d_time_end = this.$refs["end-time"].valueAsDate;
|
||||
// date_end.setHours(d_time_end.getHours());
|
||||
// date_end.setMinutes(d_time_end.getMinutes() + d_time_end.getTimezoneOffset());
|
||||
// date_end.setSeconds(d_time_end.getSeconds());
|
||||
// let epoch_begin = this.get_utc_seconds(date_begin.valueOf());
|
||||
// let epoch_end = this.get_utc_seconds(date_end.valueOf());
|
||||
let now_s = this.get_utc_seconds(Date.now());
|
||||
let begin_date = FormatterUtils.server_date_to_date(this.flat_begin_date.selectedDates[0]);
|
||||
let epoch_begin = this.get_utc_seconds(begin_date.getTime());
|
||||
|
|
@ -276,16 +264,6 @@ export default {
|
|||
let status = { epoch_begin, epoch_end };
|
||||
this.emit_epoch_change(status);
|
||||
},
|
||||
// set_date_time: function(ref_name, utc_ts, is_time) {
|
||||
// utc_ts = this.get_utc_seconds(utc_ts) * 1000;
|
||||
// let date_time = new Date(utc_ts);
|
||||
// date_time.setMinutes(date_time.getMinutes() - date_time.getTimezoneOffset());
|
||||
// if (is_time) {
|
||||
// this.$refs[ref_name].value = date_time.toISOString().substring(11,16);
|
||||
// } else {
|
||||
// this.$refs[ref_name].value = date_time.toISOString().substring(0,10);
|
||||
// }
|
||||
// },
|
||||
change_select_time: function (refresh_data) {
|
||||
let epoch_end;
|
||||
let epoch_begin;
|
||||
|
|
@ -433,9 +411,107 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date_time_input {
|
||||
width: 10.5rem;
|
||||
max-width: 10.5rem;
|
||||
min-width: 10.5rem;
|
||||
/* Toolbar bar container */
|
||||
.dtrp-bar {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Time preset selector */
|
||||
.dtrp-preset {
|
||||
min-width: 7rem;
|
||||
max-width: 11rem;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* Flatpickr date inputs */
|
||||
.dtrp-input {
|
||||
width: 9rem;
|
||||
min-width: 7rem;
|
||||
max-width: 9.5rem;
|
||||
background-color: var(--input-bg, #fff) !important;
|
||||
border: 1px solid var(--input-border, #ced4da) !important;
|
||||
color: var(--ntop-text-color, #495057) !important;
|
||||
font-size: 0.8rem !important;
|
||||
height: 28px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 7px !important;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.dtrp-input:focus {
|
||||
border-color: var(--ntop-orange, #FF8F00) !important;
|
||||
box-shadow: 0 0 0 2px rgba(255, 143, 0, 0.18) !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dtrp-input:disabled {
|
||||
background-color: var(--bg-sunken, #f1f3f5) !important;
|
||||
color: var(--ntop-disabled-text-color, rgba(33, 37, 41, 0.5)) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Arrow separator */
|
||||
.dtrp-arrow {
|
||||
color: var(--ntop-muted-text-color, #37474F);
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Error indicator */
|
||||
.dtrp-error {
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Shared button base */
|
||||
.dtrp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--border-color, #dee2e6);
|
||||
border-radius: 7px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.65rem;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--ntop-muted-text-color, #37474F);
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
white-space: nowrap;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dtrp-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Apply button */
|
||||
.dtrp-btn-primary {
|
||||
background: var(--bs-primary);
|
||||
color: #fff;
|
||||
border-color: var(--bs-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dtrp-btn-primary:not(:disabled):hover {
|
||||
background: var(--bs-primary-text-emphasis);
|
||||
border-color: var(--bs-primary-text-emphasis);
|
||||
}
|
||||
|
||||
/* Icon-only refresh button */
|
||||
.dtrp-btn-icon {
|
||||
width: 28px;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.dtrp-btn-icon:not(:disabled):hover {
|
||||
border-color: var(--ntop-orange, #FF8F00);
|
||||
color: var(--ntop-orange, #FF8F00);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ import { default as PageDefsOverview } from "./page-defs-overview.vue"
|
|||
import { default as PageChecksOverview } from "./page-checks-overview.vue"
|
||||
import { default as PageManageData } from "./page-manage-data.vue"
|
||||
import { default as PageEditDeviceProtocols } from "./page-edit-device-protocols.vue"
|
||||
import { default as PageAiStats } from "./page-ai-stats.vue"
|
||||
//import { default as PageInternals } from "./page-internals.vue"
|
||||
|
||||
/* Testing page */
|
||||
import { default as PageTest } from "./page-test.vue";
|
||||
|
|
@ -251,6 +253,8 @@ let ntopVue = {
|
|||
PageExporterMap: PageExporterMap,
|
||||
PageManageData: PageManageData,
|
||||
PageEditDeviceProtocols: PageEditDeviceProtocols,
|
||||
PageAiStats: PageAiStats,
|
||||
//PageInternals: PageInternals,
|
||||
|
||||
PageExportersGraph: PageExportersGraph,
|
||||
PageAbout: PageAbout,
|
||||
|
|
|
|||
651
http_src/vue/page-ai-stats.vue
Normal file
651
http_src/vue/page-ai-stats.vue
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
<template>
|
||||
<div class="ai-stats-page p-3">
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="ai-filter-card mb-3">
|
||||
<div class="ai-filter-row">
|
||||
|
||||
<!-- Time range selectors -->
|
||||
<div class="ai-filter-group">
|
||||
<label class="ai-filter-label">
|
||||
<i class="fas fa-clock me-1"></i>{{ _i18n('llm.time_range')}}
|
||||
</label>
|
||||
<div class="d-flex gap-1">
|
||||
<button v-for="r in timeRanges" :key="r.value" class="ai-range-pill"
|
||||
:class="{ active: selectedRange === r.value }" @click="selectedRange = r.value; applyFilters()">
|
||||
{{ _i18n(r.label) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-filter-divider"></div>
|
||||
|
||||
<!-- Provider -->
|
||||
<div class="ai-filter-group">
|
||||
<label class="ai-filter-label">{{ _i18n('llm.provider') }}</label>
|
||||
<select class="ai-select" v-model="selectedProvider" @change="applyFilters">
|
||||
<option value="">{{ _i18n('llm.all_providers') }}</option>
|
||||
<option v-for="p in availableProviders" :key="p" :value="p">{{ providerLabel(p) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div class="ai-filter-group">
|
||||
<label class="ai-filter-label">{{ _i18n('llm.model') }}</label>
|
||||
<select class="ai-select" v-model="selectedModel" @change="applyFilters">
|
||||
<option value="">{{ _i18n('llm.all_models') }}</option>
|
||||
<option v-for="m in filteredModels" :key="m.model + m.provider" :value="m.model">{{ m.model }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- User (admin only) -->
|
||||
<div class="ai-filter-group" v-if="context.is_admin">
|
||||
<label class="ai-filter-label">{{ _i18n('llm.user') }}</label>
|
||||
<select class="ai-select" v-model="selectedUser" @change="applyFilters">
|
||||
<option value="">{{ _i18n('llm.all_users') }}</option>
|
||||
<option v-for="u in availableUsers" :key="u" :value="u">{{ u }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Back to chat + refresh -->
|
||||
<div class="d-flex align-items-end gap-2 ms-auto">
|
||||
<a v-if="context.chat_url" :href="context.chat_url" class="ai-back-btn">
|
||||
<i class="fas fa-comment-alt me-1"></i>{{ _i18n('llm.back_to_chat')}}
|
||||
</a>
|
||||
<button class="ai-refresh-btn" @click="applyFilters" :title="_i18n('refresh')" :disabled="loading">
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !hasData" class="d-flex justify-content-center align-items-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{ _i18n('loading')}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!loading && !hasData" class="text-center py-5">
|
||||
<i class="fas fa-database fa-3x mb-3 opacity-25"></i>
|
||||
<p class="text-muted mb-0">{{ _i18n('llm.no_usage_data') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
|
||||
<!-- KPI badges -->
|
||||
<div class="row g-3 mb-3">
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="ai-kpi-orange rounded-3 p-3">
|
||||
<BadgeComponent id="kpi-calls" :params="kpiCallsParams" :get_component_data="kpiCallsGetter"
|
||||
:set_component_attr="noopSetAttr" :filters="badgeFilters" :hideLoading="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="ai-kpi-teal rounded-3 p-3">
|
||||
<BadgeComponent id="kpi-tokens" :params="kpiTokensParams" :get_component_data="kpiTokensGetter"
|
||||
:set_component_attr="noopSetAttr" :filters="badgeFilters" :hideLoading="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="ai-kpi-blue rounded-3 p-3">
|
||||
<BadgeComponent id="kpi-avgms" :params="kpiAvgMsParams" :get_component_data="kpiAvgMsGetter"
|
||||
:set_component_attr="noopSetAttr" :filters="badgeFilters" :hideLoading="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="ai-kpi-purple rounded-3 p-3">
|
||||
<BadgeComponent id="kpi-chats" :params="kpiChatsParams" :get_component_data="kpiChatsGetter"
|
||||
:set_component_attr="noopSetAttr" :filters="badgeFilters" :hideLoading="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Token breakdown and Call type breakdown -->
|
||||
<div class="row g-3 mb-3">
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="ai-section-card h-100">
|
||||
<div class="ai-section-header">
|
||||
<span class="ai-section-title">{{ _i18n('llm.token_breakdown') }}</span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="ai-dot ai-dot-orange"></span>
|
||||
<span class="small">{{ _i18n('llm.prompt_tokens') }}</span>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="small fw-semibold">{{ fmtTokens(summary.total_prompt_tokens) }}</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary">{{ pctOf(summary.total_prompt_tokens,
|
||||
summary.total_tokens) }}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px;">
|
||||
<div class="progress-bar ai-bar-orange"
|
||||
:style="{ width: pctOf(summary.total_prompt_tokens, summary.total_tokens) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="ai-dot ai-dot-teal"></span>
|
||||
<span class="small">{{ _i18n('llm.completion_tokens') }}</span>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="small fw-semibold">{{ fmtTokens(summary.total_completion_tokens) }}</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary">{{ pctOf(summary.total_completion_tokens,
|
||||
summary.total_tokens) }}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px;">
|
||||
<div class="progress-bar ai-bar-teal"
|
||||
:style="{ width: pctOf(summary.total_completion_tokens, summary.total_tokens) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="ai-section-card h-100">
|
||||
<div class="ai-section-header">
|
||||
<span class="ai-section-title">{{ _i18n('llm.call_type_breakdown') }}</span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div v-if="byCallType.length === 0" class="text-muted small">—</div>
|
||||
<div v-for="ct in byCallType" :key="ct.call_type" class="mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span :class="callTypeBadgeClass(ct.call_type)" class="badge">{{ _i18n("llm." + ct.call_type) || ct.call_type }}</span>
|
||||
<span class="small text-muted">{{ fmtNumber(ct.calls) }}</span>
|
||||
</div>
|
||||
<div class="progress" style="height:4px;">
|
||||
<div :class="callTypeBarClass(ct.call_type)" class="progress-bar"
|
||||
:style="{ width: pctOfMax(ct.calls, maxCallTypeCalls) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tables showing models used and user usage -->
|
||||
<div class="ai-section-card mb-3">
|
||||
|
||||
<!-- Model table -->
|
||||
<div v-show="activePage === 'model'">
|
||||
<TableWithConfig ref="modelTableRef" table_config_id="llm_by_model" :f_map_config="mapModelConfig"
|
||||
:csrf="context.csrf">
|
||||
<template v-slot:custom_header>
|
||||
<NavbarTabs :tabs="tabs" :active_tab_id="activePage" @on_click="(tab) => (activePage = tab.id)" />
|
||||
</template>
|
||||
</TableWithConfig>
|
||||
</div>
|
||||
|
||||
<!-- User table -->
|
||||
<div v-show="activePage === 'user'">
|
||||
<TableWithConfig ref="userTableRef" table_config_id="llm_by_user" :f_map_config="mapUserConfig"
|
||||
:csrf="context.csrf">
|
||||
<template v-slot:custom_header>
|
||||
<NavbarTabs :tabs="tabs" :active_tab_id="activePage" @on_click="(tab) => (activePage = tab.id)" />
|
||||
</template>
|
||||
</TableWithConfig>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
import { default as BadgeComponent } from "./dashboard-badge.vue";
|
||||
import { default as TableWithConfig } from "./table-with-config.vue";
|
||||
import { default as NavbarTabs } from "./components/navbar-tabs.vue";
|
||||
|
||||
const _i18n = (t) => i18n(t);
|
||||
|
||||
const props = defineProps({
|
||||
context: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
// Time ranges
|
||||
const timeRanges = [
|
||||
{ value: "1h", label: 'llm.1h' },
|
||||
{ value: "6h", label: 'llm.6h' },
|
||||
{ value: "24h", label: 'llm.24h' },
|
||||
{ value: "7d", label: 'llm.7d' },
|
||||
{ value: "30d", label: 'llm.30d' },
|
||||
];
|
||||
const rangeSeconds = { "1h": 3600, "6h": 21600, "24h": 86400, "7d": 604800, "30d": 2592000 };
|
||||
|
||||
// Navbar Tabs
|
||||
const activePage = ref("model");
|
||||
|
||||
const tabs = [
|
||||
{ id: "model", label_i18n: "llm.usage_by_model" },
|
||||
{ id: "user", label_i18n: "llm.usage_by_user" },
|
||||
];
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const dataInitialized = ref(false);
|
||||
const selectedRange = ref("24h");
|
||||
const selectedProvider = ref("");
|
||||
const selectedModel = ref("");
|
||||
const selectedUser = ref("");
|
||||
|
||||
const availableProviders = ref([]);
|
||||
const availableModels = ref([]);
|
||||
const availableUsers = ref([]);
|
||||
|
||||
const summary = ref({});
|
||||
const byModel = ref([]);
|
||||
const byCallType = ref([]);
|
||||
const byUser = ref([]);
|
||||
|
||||
// Triggers badge refresh after data is loaded
|
||||
const badgeFilters = ref({});
|
||||
|
||||
// Table refs for manual refresh on filter changes
|
||||
const modelTableRef = ref(null);
|
||||
const userTableRef = ref(null);
|
||||
|
||||
// Computed
|
||||
const hasData = computed(() => summary.value.total_calls && parseInt(summary.value.total_calls) > 0);
|
||||
|
||||
const filteredModels = computed(() =>
|
||||
selectedProvider.value
|
||||
? availableModels.value.filter(m => m.provider === selectedProvider.value)
|
||||
: availableModels.value
|
||||
);
|
||||
|
||||
const maxCallTypeCalls = computed(() =>
|
||||
Math.max(...byCallType.value.map(c => parseInt(c.calls) || 0), 1)
|
||||
);
|
||||
|
||||
// Formatters
|
||||
function fmtNumber(v) {
|
||||
const n = parseInt(v) || 0;
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return n.toLocaleString();
|
||||
}
|
||||
function fmtTokens(v) { return fmtNumber(v); }
|
||||
function fmtMs(v) {
|
||||
const ms = parseFloat(v) || 0;
|
||||
if (ms >= 60000) return (ms / 60000).toFixed(1) + "m";
|
||||
if (ms >= 1000) return (ms / 1000).toFixed(1) + "s";
|
||||
return Math.round(ms) + "ms";
|
||||
}
|
||||
function pctOf(part, total) {
|
||||
const p = parseFloat(part) || 0, t = parseFloat(total) || 0;
|
||||
return t === 0 ? 0 : Math.round((p / t) * 100);
|
||||
}
|
||||
function pctOfMax(val, max) {
|
||||
return Math.max(2, Math.round(((parseFloat(val) || 0) / (parseFloat(max) || 1)) * 100));
|
||||
}
|
||||
|
||||
// Provider helpers
|
||||
function providerLabel(p) {
|
||||
if (p === "llm_anthropic") return _i18n("prefs.llm_anthropic");
|
||||
if (p === "llm_openai") return _i18n("prefs.llm_openai");
|
||||
if (p === "llm_local") return _i18n("prefs.llm_local");
|
||||
return p || "—";
|
||||
}
|
||||
function providerIcon(p) {
|
||||
if (p === "llm_openai") return "bi bi-openai";
|
||||
if (p === "llm_anthropic") return "bi bi-anthropic";
|
||||
return "fa-solid fa-microchip";
|
||||
}
|
||||
function callTypeBadgeClass(ct) {
|
||||
const map = { initial_call: "bg-primary-subtle text-primary", tool_followup: "bg-warning-subtle text-warning", final_response: "bg-success-subtle text-success", retry: "bg-danger-subtle text-danger" };
|
||||
return map[ct] ?? "bg-secondary-subtle text-secondary";
|
||||
}
|
||||
function callTypeBarClass(ct) {
|
||||
const map = { initial_call: "bg-primary", tool_followup: "bg-warning", final_response: "bg-success", retry: "bg-danger" };
|
||||
return map[ct] ?? "bg-secondary";
|
||||
}
|
||||
|
||||
// Dashboard badge setup
|
||||
|
||||
// We provide a custom get_component_data that reads from our already-fetched
|
||||
// summary ref. The badge watches the `filters` prop — we bump `badgeFilters`
|
||||
// after applyFilters() completes so each badge re-reads the latest data.
|
||||
//
|
||||
const noopSetAttr = () => { };
|
||||
|
||||
function makeBadgeParams(field, i18nKey, icon) {
|
||||
return { url: '/', counter_path: field, counter_formatter: 'no_formatting', i18n_name: i18nKey, icon };
|
||||
}
|
||||
|
||||
const kpiCallsParams = makeBadgeParams('calls', 'llm.stat_total_calls', 'fas fa-bolt');
|
||||
const kpiTokensParams = makeBadgeParams('tokens', 'llm.stat_total_tokens', 'fas fa-coins');
|
||||
const kpiAvgMsParams = makeBadgeParams('avgms', 'llm.stat_avg_response', 'fas fa-stopwatch');
|
||||
const kpiChatsParams = makeBadgeParams('chats', 'llm.stat_unique_chats', 'fas fa-comments');
|
||||
|
||||
const kpiCallsGetter = async () => ({ calls: fmtNumber(summary.value.total_calls) });
|
||||
const kpiTokensGetter = async () => ({ tokens: fmtTokens(summary.value.total_tokens) });
|
||||
const kpiAvgMsGetter = async () => ({ avgms: fmtMs(summary.value.avg_completion_time_ms) });
|
||||
const kpiChatsGetter = async () => ({ chats: fmtNumber(summary.value.unique_chats) });
|
||||
|
||||
// Table config mappers
|
||||
function mapModelConfig(config) {
|
||||
config.get_rows = async () => ({
|
||||
totalRowCount: byModel.value.length,
|
||||
rows: byModel.value,
|
||||
});
|
||||
|
||||
const add = (id, fn) => {
|
||||
const col = config.columns.find(c => c.id === id);
|
||||
if (col) col.render_func = fn;
|
||||
};
|
||||
|
||||
add('provider', (d) => `<span class="d-flex align-items-center gap-1"><i class="${providerIcon(d)} small opacity-75"></i><span class="text-muted small">${providerLabel(d)}</span></span>`);
|
||||
add('model', (d) => `<code class="small">${d ?? ''}</code>`);
|
||||
add('calls', (d) => fmtNumber(d));
|
||||
add('prompt_tokens', (d) => fmtTokens(d));
|
||||
add('completion_tokens', (d) => fmtTokens(d));
|
||||
add('total_tokens', (d) => `<strong>${fmtTokens(d)}</strong>`);
|
||||
add('avg_ms', (d) => fmtMs(d));
|
||||
add('max_ms', (d) => `<span class="text-muted">${fmtMs(d)}</span>`);
|
||||
return config;
|
||||
}
|
||||
|
||||
function mapUserConfig(config) {
|
||||
config.get_rows = async () => ({
|
||||
totalRowCount: byUser.value.length,
|
||||
rows: byUser.value,
|
||||
});
|
||||
const add = (id, fn) => {
|
||||
const col = config.columns.find(c => c.id === id);
|
||||
if (col) col.render_func = fn;
|
||||
};
|
||||
add('username', (d) => `<span><i class="fas fa-user-circle me-1 opacity-50"></i>${d}</span>`);
|
||||
add('calls', (d) => fmtNumber(d));
|
||||
add('total_tokens', (d) => `<strong>${fmtTokens(d)}</strong>`);
|
||||
add('prompt_tokens', (d) => fmtTokens(d));
|
||||
add('completion_tokens', (d) => fmtTokens(d));
|
||||
add('unique_chats', (d) => fmtNumber(d));
|
||||
add('avg_ms', (d) => fmtMs(d));
|
||||
add('token_share', (d) => {
|
||||
const pct = pctOf(d, summary.value.total_tokens);
|
||||
return `<div class="progress mb-1" style="height:5px;"><div class="progress-bar bg-primary" style="width:${pct}%"></div></div><span class="text-muted" style="font-size:0.7rem;">${pct}%</span>`;
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
// API calls
|
||||
async function loadFilters() {
|
||||
try {
|
||||
const r = await fetch(`${http_prefix}/lua/pro/rest/v2/get/llm/usage_filters.lua`);
|
||||
const json = await r.json();
|
||||
const data = json.rsp || {};
|
||||
availableModels.value = Array.isArray(data.models) ? data.models : [];
|
||||
availableUsers.value = (Array.isArray(data.users) ? data.users : []).map(u => u.username).filter(Boolean);
|
||||
availableProviders.value = [...new Set(availableModels.value.map(m => m.provider).filter(Boolean))];
|
||||
} catch (e) {
|
||||
console.error("Failed to load AI usage filters", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
loading.value = true;
|
||||
const wasInit = dataInitialized.value;
|
||||
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const secs = rangeSeconds[selectedRange.value] || 86400;
|
||||
const params = new URLSearchParams({ epoch_begin: now - secs, epoch_end: now });
|
||||
|
||||
if (selectedProvider.value) params.set("provider", selectedProvider.value);
|
||||
if (selectedModel.value) params.set("model", selectedModel.value);
|
||||
if (selectedUser.value) params.set("username", selectedUser.value);
|
||||
|
||||
const r = await fetch(`${http_prefix}/lua/pro/rest/v2/get/llm/token_usage.lua?${params.toString()}`);
|
||||
const json = await r.json();
|
||||
const data = json.rsp || {};
|
||||
|
||||
summary.value = data.summary || {};
|
||||
byModel.value = Array.isArray(data.by_model) ? data.by_model : [];
|
||||
byCallType.value = Array.isArray(data.by_call_type) ? data.by_call_type : [];
|
||||
byUser.value = Array.isArray(data.by_user) ? data.by_user : [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load AI usage stats", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
dataInitialized.value = true;
|
||||
// Trigger badge refresh now that summary is populated
|
||||
badgeFilters.value = { _tick: Date.now() };
|
||||
|
||||
// On subsequent filter changes, tables are already mounted — refresh them
|
||||
if (wasInit) {
|
||||
await nextTick();
|
||||
modelTableRef.value?.refresh_table(true);
|
||||
userTableRef.value?.refresh_table(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFilters();
|
||||
await applyFilters();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-stats-page {
|
||||
--ai-orange: var(--ntop-orange, #FF8F00);
|
||||
--ai-border: var(--chat-border, rgba(0, 0, 0, 0.10));
|
||||
--ai-header-bg: var(--navbar-tab-container-bg, #f1f3f5);
|
||||
--ai-card-bg: var(--bs-body-bg, #ffffff);
|
||||
--ai-muted: var(--ntop-muted-text-color, #37474F);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .ai-stats-page {
|
||||
--ai-border: rgba(255, 255, 255, 0.08);
|
||||
--ai-header-bg: #111c24;
|
||||
--ai-card-bg: #1a2736;
|
||||
}
|
||||
|
||||
/* Filter card */
|
||||
.ai-filter-card {
|
||||
background: var(--ai-card-bg);
|
||||
border: 1px solid var(--ai-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
.ai-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.ai-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ai-filter-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ai-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ai-filter-divider {
|
||||
width: 1px;
|
||||
height: 34px;
|
||||
background: var(--ai-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-range-pill {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ai-border);
|
||||
background: transparent;
|
||||
color: var(--ai-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.ai-range-pill:hover {
|
||||
background: rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
.ai-range-pill.active {
|
||||
background: rgba(255, 143, 0, .12);
|
||||
border-color: var(--ai-orange);
|
||||
color: var(--ai-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-select {
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--ai-border);
|
||||
border-radius: 7px;
|
||||
padding: 0.25rem 1.6rem 0.25rem 0.55rem;
|
||||
appearance: none;
|
||||
background-color: var(--ai-card-bg);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath fill='%236c757d' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.45rem center;
|
||||
background-size: 8px;
|
||||
color: inherit;
|
||||
min-width: 120px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.ai-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ai-orange);
|
||||
box-shadow: 0 0 0 2px rgba(255, 143, 0, .18);
|
||||
}
|
||||
|
||||
.ai-back-btn {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ai-muted);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--ai-border);
|
||||
border-radius: 7px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.ai-back-btn:hover {
|
||||
border-color: var(--ai-orange);
|
||||
color: var(--ai-orange);
|
||||
}
|
||||
|
||||
.ai-refresh-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--ai-border);
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--ai-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.ai-refresh-btn:hover:not(:disabled) {
|
||||
border-color: var(--ai-orange);
|
||||
color: var(--ai-orange);
|
||||
}
|
||||
|
||||
.ai-refresh-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* KPI badge wrappers */
|
||||
.ai-kpi-orange {
|
||||
background: var(--ntop-orange, #FF8F00);
|
||||
}
|
||||
|
||||
.ai-kpi-teal {
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
.ai-kpi-blue {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.ai-kpi-purple {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* Section cards */
|
||||
.ai-section-card {
|
||||
background: var(--ai-card-bg);
|
||||
border: 1px solid var(--ai-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.ai-section-header {
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--ai-border);
|
||||
background: var(--ai-header-bg);
|
||||
}
|
||||
|
||||
.ai-section-title {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--ai-muted);
|
||||
}
|
||||
|
||||
/* Token dot indicators */
|
||||
.ai-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-dot-orange {
|
||||
background: var(--ai-orange);
|
||||
}
|
||||
|
||||
.ai-dot-teal {
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
/* Progress bar colors */
|
||||
.ai-bar-orange {
|
||||
background: var(--ai-orange) !important;
|
||||
}
|
||||
|
||||
.ai-bar-teal {
|
||||
background: #0d9488 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -92,7 +92,6 @@ const updateSankeyData = async () => {
|
|||
/* ************************************** */
|
||||
|
||||
const changedOption = (opt) => {
|
||||
debugger
|
||||
ntopng_url_manager.set_key_to_url(opt.filter_name, opt.id)
|
||||
updateSankeyData();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -431,7 +431,6 @@ function click_button_edit(event) {
|
|||
|
||||
function click_button_timeseries(event) {
|
||||
const row = event.row;
|
||||
debugger;
|
||||
window.location.href = `${linksUtils.getSNMPDetailsPageURL(row.column_ip, http_prefix)}&page=historical`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,19 +7,29 @@
|
|||
<date-time-range-picker :id="id_data_time_range_picker" :min_time_interval_id="min_time_interval_id"
|
||||
:round_time="round_time">
|
||||
<template v-slot:begin>
|
||||
<div v-if="is_alert_stats_url" style="margin-right:0.1rem;" class="d-flex align-items-center me-2">
|
||||
<div class="btn-group" id="statusSwitch" role="group">
|
||||
<a v-if="page != 'flow'" href="#" @click="update_status_view('engaged')" class="btn btn-sm"
|
||||
:class="{ 'active': status_view == 'engaged', 'btn-seconday': status_view != 'engaged', 'btn-primary': status_view == 'engaged' }"><i
|
||||
class="fa-solid fa-fire" title="Engaged"></i></a>
|
||||
<a href="#" @click="update_status_view('historical')" class="btn btn-sm"
|
||||
:class="{ 'active': status_view == 'historical' || (page == 'flow' && status_view == 'engaged'), 'btn-seconday': status_view != 'historical', 'btn-primary': status_view == 'historical' || (page == 'flow' && status_view == 'engaged') }"><i
|
||||
class="fa-regular fa-eye" title="Require Attention"></i></a>
|
||||
<!-- <a href="#" @click="update_status_view('acknowledged')" class="btn btn-sm"
|
||||
:class="{ 'active': status_view == 'acknowledged', 'btn-seconday': status_view != 'acknowledged', 'btn-primary': status_view == 'acknowledged' }"><i class="fa-solid fa-check-double" title="Acknowledged"></i></a>-->
|
||||
<a href="#" @click="update_status_view('any')" class="btn btn-sm"
|
||||
:class="{ 'active': status_view == 'any', 'btn-seconday': status_view != 'any', 'btn-primary': status_view == 'any' }"><i
|
||||
class="fa-solid fa-inbox" title="All"></i></a>
|
||||
<div v-if="is_alert_stats_url" class="d-flex align-items-center me-2">
|
||||
<div class="rp-status-group" role="group">
|
||||
<a v-if="page != 'flow'" href="#"
|
||||
@click="update_status_view('engaged')"
|
||||
class="rp-status-btn"
|
||||
:class="{ active: status_view == 'engaged' }"
|
||||
title="Engaged">
|
||||
<i class="fa-solid fa-fire"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
@click="update_status_view('historical')"
|
||||
class="rp-status-btn"
|
||||
:class="{ active: status_view == 'historical' || (page == 'flow' && status_view == 'engaged') }"
|
||||
title="Require Attention">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
@click="update_status_view('any')"
|
||||
class="rp-status-btn"
|
||||
:class="{ active: status_view == 'any' }"
|
||||
title="All">
|
||||
<i class="fa-solid fa-inbox"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="begin"></slot>
|
||||
|
|
@ -30,19 +40,23 @@
|
|||
</date-time-range-picker>
|
||||
</div>
|
||||
|
||||
<!-- tagify -->
|
||||
<div v-if="page != 'all'" class="d-flex mt-1" style="width:100%">
|
||||
<input class="w-100 form-control h-auto" name="tags" ref="tagify"
|
||||
<!-- tagify filter bar -->
|
||||
<div v-if="page != 'all'" class="rp-filter-bar d-flex mt-1 align-items-center gap-1" style="width:100%">
|
||||
<input class="w-100 form-control form-control-sm rp-tagify-input h-auto" name="tags" ref="tagify"
|
||||
:placeholder="i18n('show_alerts.filters')">
|
||||
|
||||
<button v-show="modal_data && modal_data.length > 0" class="btn btn-link" aria-controls="flow-alerts-table"
|
||||
type="button" id="btn-add-alert-filter" @click="show_modal_filters"><span><i class="fas fa-plus"
|
||||
data-original-title="" title="Add Filter"></i></span>
|
||||
<button v-show="modal_data && modal_data.length > 0"
|
||||
class="rp-icon-btn" type="button"
|
||||
@click="show_modal_filters"
|
||||
title="Add Filter">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
|
||||
<button v-show="modal_data && modal_data.length > 0" data-bs-toggle="tooltip" data-placement="bottom"
|
||||
:title="i18n('show_alerts.remove_filters')" @click="remove_filters"
|
||||
class="btn ms-1 my-auto btn-sm btn-remove-tags">
|
||||
<button v-show="modal_data && modal_data.length > 0"
|
||||
:title="i18n('show_alerts.remove_filters')"
|
||||
@click="remove_filters"
|
||||
class="rp-icon-btn rp-icon-btn--danger"
|
||||
type="button">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -381,31 +395,133 @@ function create_tagify(range_picker_vue) {
|
|||
|
||||
|
||||
<style scoped>
|
||||
.tagify__input {
|
||||
/* Status view toggle */
|
||||
.rp-status-group {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rp-status-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--ntop-muted-text-color, #37474F);
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
text-decoration: none;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rp-status-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.rp-status-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--ntop-text-color, #111);
|
||||
}
|
||||
|
||||
.rp-status-btn.active {
|
||||
background: rgba(255, 143, 0, 0.12);
|
||||
color: var(--ntop-orange, #FF8F00);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .rp-status-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/* Tagify filter input */
|
||||
.rp-tagify-input {
|
||||
background: var(--input-bg, #fff) !important;
|
||||
border-color: var(--input-border, #ced4da) !important;
|
||||
color: var(--input-text, #495057) !important;
|
||||
font-size: 0.8rem;
|
||||
min-height: 28px;
|
||||
height: auto;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* Icon action buttons */
|
||||
.rp-icon-btn {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--ntop-muted-text-color, #37474F);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rp-icon-btn:hover {
|
||||
border-color: var(--ntop-orange, #FF8F00);
|
||||
color: var(--ntop-orange, #FF8F00);
|
||||
}
|
||||
|
||||
.rp-icon-btn--danger:hover {
|
||||
border-color: rgba(220, 53, 69, 0.4);
|
||||
color: #dc3545;
|
||||
background: rgba(220, 53, 69, 0.05);
|
||||
}
|
||||
|
||||
/* Tagify component overrides */
|
||||
:deep(.tagify) {
|
||||
background: var(--input-bg, #fff);
|
||||
border-color: var(--input-border, #ced4da);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
:deep(.tagify__input) {
|
||||
min-width: 175px;
|
||||
color: var(--input-text, #495057);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tagify__tag {
|
||||
:deep(.tagify__tag) {
|
||||
background: var(--bg-elevated, #f8f9fa);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
margin: 3px 0px 5px 5px;
|
||||
margin: 2px 0 4px 4px;
|
||||
}
|
||||
|
||||
.tagify__tag select.operator {
|
||||
margin: 0px 4px;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tagify__tag b.operator {
|
||||
margin: 0px 4px;
|
||||
background-color: white;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
padding: 0.05em 0.2em;
|
||||
}
|
||||
|
||||
.tagify__tag>div {
|
||||
:deep(.tagify__tag > div) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
:deep(.tagify__tag b.operator) {
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 3px;
|
||||
padding: 0.05em 0.25em;
|
||||
margin: 0 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.tagify__tag__removeBtn) {
|
||||
color: var(--ntop-muted-text-color, #37474F);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
:deep(.tagify__tag__removeBtn:hover) {
|
||||
color: #dc3545;
|
||||
background: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
<template>
|
||||
<!-- Select2 wrapper component with Vue integration -->
|
||||
<select class="select2 form-select" ref="select2" required name="filter_type" :multiple="multiple"
|
||||
:disabled="disabled">
|
||||
<!-- Render regular options (without groups) -->
|
||||
<option class="no-wrap p-0" v-for="(item, i) in options_2" :selected="is_selected(item)" :value="item.value"
|
||||
:disabled="item.disabled" :data-icon="item.icon">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
<!-- Render grouped options with optgroup elements -->
|
||||
<optgroup v-for="(item, i) in groups_options_2" :label="item.group">
|
||||
<option v-for="(opt, j) in item.options" :selected="is_selected(opt)" :value="opt.value"
|
||||
:disabled="opt.disabled" :data-icon="item.icon">
|
||||
{{ opt.label }}
|
||||
<div class="ss-root">
|
||||
<select class="select2 form-select" ref="select2" required name="filter_type" :multiple="multiple"
|
||||
:disabled="disabled">
|
||||
<!-- Render regular options (without groups) -->
|
||||
<option class="no-wrap p-0" v-for="(item, i) in options_2" :selected="is_selected(item)" :value="item.value"
|
||||
:disabled="item.disabled" :data-icon="item.icon">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<!-- Render grouped options with optgroup elements -->
|
||||
<optgroup v-for="(item, i) in groups_options_2" :label="item.group">
|
||||
<option v-for="(opt, j) in item.options" :selected="is_selected(opt)" :value="opt.value"
|
||||
:disabled="opt.disabled" :data-icon="item.icon">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -123,11 +125,11 @@ function set_input() {
|
|||
* 3. Separates options based on the presence of 'group' property
|
||||
* 4. Groups options by their group property using a dictionary
|
||||
* 5. Converts the groups dictionary to an array format
|
||||
*
|
||||
*
|
||||
* The resulting structure:
|
||||
* - options_2: Array of options without groups
|
||||
* - groups_options_2: Array of grouped options with structure {group: string, options: array}
|
||||
*
|
||||
*
|
||||
* @throws Will skip processing if props.options is null
|
||||
* @sideeffect Updates options_2, groups_options_2, and increments refresh_options
|
||||
*/
|
||||
|
|
@ -165,7 +167,7 @@ function set_options() {
|
|||
/**
|
||||
* Custom search matcher function for Select2 with hierarchical search support.
|
||||
* This function implements case-insensitive searching that works across nested option groups.
|
||||
*
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Normalize search term and text to lowercase for case-insensitive comparison
|
||||
* 2. If no search term is provided, return all data (no filtering)
|
||||
|
|
@ -173,21 +175,21 @@ function set_options() {
|
|||
* 4. If no match, recursively search through child items (for grouped options)
|
||||
* 5. If children match, return the parent with filtered children only
|
||||
* 6. Return null if neither parent nor children match
|
||||
*
|
||||
*
|
||||
* @param {Object} params - Select2 search parameters
|
||||
* @param {string} params.term - The search term entered by the user
|
||||
* @param {Object} data - The option data object from Select2
|
||||
* @param {string} data.text - Display text of the option
|
||||
* @param {Array} [data.children] - Child options for grouped items
|
||||
* @returns {Object|null} - Modified data object with filtered children, original data, or null if no match
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* // Returns data unchanged
|
||||
* matchCustom({term: ''}, {text: 'Option 1'})
|
||||
*
|
||||
*
|
||||
* // Returns data if text contains 'opt'
|
||||
* matchCustom({term: 'opt'}, {text: 'Option 1'})
|
||||
*
|
||||
*
|
||||
* // Returns parent with filtered children
|
||||
* matchCustom({term: 'child'}, {text: 'Parent', children: [{text: 'Child 1'}, {text: 'Other'}]})
|
||||
*/
|
||||
|
|
@ -241,7 +243,7 @@ function matchCustom(params, data) {
|
|||
/**
|
||||
* Format option display with optional icon.
|
||||
* This function enhances option rendering by adding icon support through Font Awesome or similar icon libraries.
|
||||
*
|
||||
*
|
||||
* @param {Object} option - Select2 option object
|
||||
* @param {string} option.id - Option identifier
|
||||
* @param {string} option.text - Option display text
|
||||
|
|
@ -279,17 +281,17 @@ const formatOption = (option) => {
|
|||
* 2. Initializes Select2 with custom configuration
|
||||
* 3. Sets up event handlers for selection changes
|
||||
* 4. Synchronizes Vue state with Select2 state
|
||||
*
|
||||
*
|
||||
* Configuration includes:
|
||||
* - Custom matcher for hierarchical search
|
||||
* - Theme customization
|
||||
* - Tagging support (when enabled)
|
||||
* - Size variants via CSS classes
|
||||
*
|
||||
*
|
||||
* Event handling:
|
||||
* - select2:select: Handles both regular selections and custom tag creation
|
||||
* - select2:unselect: Manages removal from multiple selections
|
||||
*
|
||||
*
|
||||
* @sideeffect Modifies DOM, sets up jQuery event listeners, updates first_time_render flag
|
||||
* @throws May throw if Select2 initialization fails or jQuery is not available
|
||||
*/
|
||||
|
|
@ -311,7 +313,7 @@ const render = () => {
|
|||
selectionCssClass: props.dropdown_size == "small" ? 'select2--small' : '', // Size variant
|
||||
dropdownCssClass: props.dropdown_size == "small" ? 'select2--small' : '' // Size variant
|
||||
});
|
||||
|
||||
|
||||
// Handle option selection event
|
||||
$(select2Div).on('select2:select', function (e) {
|
||||
let data = e.params.data;
|
||||
|
|
@ -325,16 +327,16 @@ const render = () => {
|
|||
}
|
||||
let value = data.element._value; // Get actual value from DOM element
|
||||
let option = find_option_from_value_or_label(value); // Find original option object
|
||||
|
||||
|
||||
if (value !== props.selected_option) {
|
||||
emit('update:selected_option', option);
|
||||
emit('select_option', option);
|
||||
}
|
||||
|
||||
|
||||
if (!props.multiple) {
|
||||
return; // Single select - done
|
||||
}
|
||||
|
||||
|
||||
// Update selected values for multiple select
|
||||
selected_values.value = selected_values.value.filter((v) => v != value);
|
||||
selected_values.value.push(value);
|
||||
|
|
@ -342,7 +344,7 @@ const render = () => {
|
|||
emit('update:selected_options', options);
|
||||
emit('change_selected_options', options);
|
||||
});
|
||||
|
||||
|
||||
// Handle option unselection event (multiple select only)
|
||||
$(select2Div).on('select2:unselect', function (e) {
|
||||
let data = e.params.data;
|
||||
|
|
@ -370,15 +372,15 @@ const render = () => {
|
|||
/**
|
||||
* Synchronize Select2's displayed value with Vue's internal state.
|
||||
* This function ensures the Select2 UI reflects the current selection state.
|
||||
*
|
||||
*
|
||||
* For single select mode:
|
||||
* - Extracts value from selected option object
|
||||
* - Sets Select2 value and triggers change event
|
||||
*
|
||||
*
|
||||
* For multiple select mode:
|
||||
* - Uses the array of selected values
|
||||
* - Updates Select2 with all selected values
|
||||
*
|
||||
*
|
||||
* @sideeffect Modifies Select2 DOM element value and triggers change events
|
||||
*/
|
||||
function change_select_2_selected_value() {
|
||||
|
|
@ -400,15 +402,15 @@ function change_select_2_selected_value() {
|
|||
/**
|
||||
* Determine if an option should be marked as selected in the rendered HTML.
|
||||
* This function handles the logic for both single and multiple selection modes.
|
||||
*
|
||||
*
|
||||
* Single select logic:
|
||||
* 1. Compares option value with selected option value (strict equality)
|
||||
* 2. Special handling for zero values: also matches by label if value is 0 or "0"
|
||||
*
|
||||
*
|
||||
* Multiple select logic:
|
||||
* 1. Checks if value exists in selected_values array
|
||||
* 2. Falls back to option's own 'selected' property
|
||||
*
|
||||
*
|
||||
* @param {Object} item - The option object to check
|
||||
* @param {string|number} item.value - Option value
|
||||
* @param {string} item.label - Option display label
|
||||
|
|
@ -428,7 +430,7 @@ function is_selected(item) {
|
|||
* Initialize selected values array from props for multiple select mode.
|
||||
* This function converts an array of option objects into an array of values.
|
||||
* Each option's value is extracted (with label as fallback) and added to selected_values.
|
||||
*
|
||||
*
|
||||
* @sideeffect Updates selected_values reactive array
|
||||
* @note Only executes in multiple select mode and when selected_options is provided
|
||||
*/
|
||||
|
|
@ -447,7 +449,7 @@ function set_selected_values() {
|
|||
* Set the internal selected option state for single select mode.
|
||||
* This function handles null/undefined cases by falling back to the first option
|
||||
* when no selection is provided and not in multiple select mode.
|
||||
*
|
||||
*
|
||||
* @param {Object|null} selected_option - The option to select, or null for default
|
||||
* @sideeffect Updates selected_option_2 reactive reference
|
||||
*/
|
||||
|
|
@ -461,7 +463,7 @@ function set_selected_option(selected_option) {
|
|||
/**
|
||||
* Get the selected option from props with safe fallback logic.
|
||||
* This function provides a default selection when none is specified.
|
||||
*
|
||||
*
|
||||
* @returns {Object} - The selected option from props, or the first option if none selected
|
||||
* @throws May return undefined if props.options is empty
|
||||
*/
|
||||
|
|
@ -476,7 +478,7 @@ function get_props_selected_option() {
|
|||
* Extract the display value from a selected option object.
|
||||
* This function handles the optional nature of the 'value' property by using
|
||||
* label as a fallback when value is not provided.
|
||||
*
|
||||
*
|
||||
* @param {Object|null} selected_option - The option object
|
||||
* @param {string|number} [selected_option.value] - Optional value property
|
||||
* @param {string} selected_option.label - Display label (used as fallback value)
|
||||
|
|
@ -503,7 +505,7 @@ function get_value_from_selected_option(selected_option) {
|
|||
* Convert an array of string/number values to an array of full option objects.
|
||||
* This function maps each value through find_option_from_value_or_label to
|
||||
* retrieve the complete option object with all its properties.
|
||||
*
|
||||
*
|
||||
* @param {Array<string|number>} values - Array of option values
|
||||
* @returns {Array<Object>} - Array of corresponding option objects
|
||||
*/
|
||||
|
|
@ -516,11 +518,11 @@ function find_options_from_values(values) {
|
|||
* Find the original option object from the props.options array by value or label.
|
||||
* This function bridges between the internal processed options and the original
|
||||
* props to ensure event emissions contain the original option objects.
|
||||
*
|
||||
*
|
||||
* Process:
|
||||
* 1. Finds the processed option in options_2 or groups_options_2
|
||||
* 2. Uses that option's value or label to locate the original in props.options
|
||||
*
|
||||
*
|
||||
* @param {string|number} value - The value to search for
|
||||
* @returns {Object} - The original option object from props.options
|
||||
*/
|
||||
|
|
@ -534,11 +536,11 @@ function find_option_from_value_or_label(value) {
|
|||
* Find an option from the internal processed collections by its value.
|
||||
* This function searches through both regular and grouped options using
|
||||
* strict equality comparison (===) for accurate matching.
|
||||
*
|
||||
*
|
||||
* Search order:
|
||||
* 1. Regular options (options_2)
|
||||
* 2. Grouped options (groups_options_2)
|
||||
*
|
||||
*
|
||||
* @param {string|number} value - The value to search for
|
||||
* @returns {Object|null} - The found option object or null if not found
|
||||
*/
|
||||
|
|
@ -549,7 +551,7 @@ function find_option_2_from_value(value) {
|
|||
// Search regular options first
|
||||
let option = options_2.value.find((o) => o.value === value);
|
||||
if (option != null) { return option; }
|
||||
|
||||
|
||||
// Search in grouped options if not found in regular options
|
||||
for (let i = 0; i < groups_options_2.value.length; i += 1) {
|
||||
let g = groups_options_2.value[i];
|
||||
|
|
@ -569,11 +571,11 @@ function find_option_2_from_value(value) {
|
|||
* Clean up Select2 instance and event listeners to prevent memory leaks.
|
||||
* This function safely destroys the Select2 plugin and removes all jQuery
|
||||
* event handlers attached to the element.
|
||||
*
|
||||
*
|
||||
* Error handling:
|
||||
* - Wraps destruction in try-catch to prevent unmount errors
|
||||
* - Logs errors without throwing to avoid disrupting component lifecycle
|
||||
*
|
||||
*
|
||||
* @sideeffect Removes Select2 from DOM, clears event listeners
|
||||
*/
|
||||
function destroy() {
|
||||
|
|
@ -594,4 +596,154 @@ onBeforeUnmount(() => {
|
|||
// Expose render function to parent components for manual re-rendering
|
||||
defineExpose({ render });
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ss-root {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.select2-container) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection) {
|
||||
background-color: var(--input-bg, #fff);
|
||||
border: 1px solid var(--input-border, #ced4da);
|
||||
color: var(--input-text, #495057);
|
||||
border-radius: 7px;
|
||||
font-size: 0.8rem;
|
||||
min-height: 30px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5.select2-container--focus .select2-selection),
|
||||
:deep(.select2-container--bootstrap-5.select2-container--open .select2-selection) {
|
||||
border-color: var(--ntop-orange, #FF8F00);
|
||||
box-shadow: 0 0 0 2px rgba(255, 143, 0, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection--single) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection--single .select2-selection__rendered) {
|
||||
color: var(--input-text, #495057);
|
||||
line-height: 1 !important;
|
||||
padding-left: 0.55rem;
|
||||
padding-right: 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap !important;
|
||||
word-break: normal !important;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection--single .select2-selection__arrow) {
|
||||
height: 100% !important;
|
||||
top: 0 !important;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-dropdown) {
|
||||
background-color: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-color, #dee2e6);
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-search--dropdown) {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle, #e9ecef);
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-search--dropdown .select2-search__field) {
|
||||
background-color: var(--input-bg, #fff);
|
||||
border: 1px solid var(--input-border, #ced4da);
|
||||
border-radius: 5px;
|
||||
color: var(--input-text, #495057);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-results__option) {
|
||||
color: var(--ntop-text-color, #111);
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-results__option--highlighted[aria-selected]) {
|
||||
background-color: var(--ntop-blue, #37474F);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-results__option[aria-selected=true]) {
|
||||
background-color: var(--bg-elevated, #f8f9fa);
|
||||
color: var(--ntop-text-color, #111);
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-results__group) {
|
||||
color: var(--ntop-muted-text-color, #37474F);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.4rem 0.625rem 0.15rem;
|
||||
}
|
||||
|
||||
/* Small size variant */
|
||||
:deep(.select2--small.select2-container--bootstrap-5 .select2-selection--single) {
|
||||
min-height: 26px !important;
|
||||
height: 26px !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.select2--small .select2-selection--single .select2-selection__rendered) {
|
||||
line-height: 1 !important;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
:deep(.select2--small .select2-results__option) {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Multiple selection chips */
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__choice) {
|
||||
background-color: var(--ntop-blue, #37474F);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__choice__remove) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
:deep(.select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__choice__remove:hover) {
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
:deep(.select2-container--bootstrap-5.select2-container--disabled .select2-selection) {
|
||||
background-color: var(--bg-sunken, #f1f3f5);
|
||||
color: var(--ntop-disabled-text-color, rgba(33, 37, 41, 0.5));
|
||||
cursor: not-allowed;
|
||||
border-color: var(--border-subtle, #e9ecef);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@ function clampDygraphLegend() {
|
|||
if (!legend.value) return;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
|
||||
const rect = legend.value.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 9c43d80dd4c7502047368143396b78066f72fa02
|
||||
Subproject commit e17e953764363785978d63f51ea6ced4440fb0b4
|
||||
74
httpdocs/tables_config/llm_by_model.json
Normal file
74
httpdocs/tables_config/llm_by_model.json
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"id": "llm_by_model",
|
||||
"paging": false,
|
||||
"display_empty_rows": false,
|
||||
"enable_search": true,
|
||||
"columns": [
|
||||
{
|
||||
"id": "provider",
|
||||
"title_i18n": "llm.provider",
|
||||
"data_field": "provider",
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"id": "model",
|
||||
"title_i18n": "llm.model",
|
||||
"data_field": "model",
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"id": "calls",
|
||||
"title_i18n": "llm.calls",
|
||||
"data_field": "calls",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prompt_tokens",
|
||||
"title_i18n": "llm.prompt_tokens",
|
||||
"data_field": "prompt_tokens",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "completion_tokens",
|
||||
"title_i18n": "llm.completion_tokens",
|
||||
"data_field": "completion_tokens",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "total_tokens",
|
||||
"title_i18n": "llm.total_tokens",
|
||||
"data_field": "total_tokens",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "avg_ms",
|
||||
"title_i18n": "llm.avg_ms",
|
||||
"data_field": "avg_ms",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "max_ms",
|
||||
"title_i18n": "llm.max_ms",
|
||||
"data_field": "max_ms",
|
||||
"sortable": false,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
74
httpdocs/tables_config/llm_by_user.json
Normal file
74
httpdocs/tables_config/llm_by_user.json
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"id": "llm_by_user",
|
||||
"paging": false,
|
||||
"display_empty_rows": false,
|
||||
"enable_search": true,
|
||||
"columns": [
|
||||
{
|
||||
"id": "username",
|
||||
"title_i18n": "llm.user",
|
||||
"data_field": "username",
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"id": "calls",
|
||||
"title_i18n": "llm.calls",
|
||||
"data_field": "calls",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "total_tokens",
|
||||
"title_i18n": "llm.total_tokens",
|
||||
"data_field": "total_tokens",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prompt_tokens",
|
||||
"title_i18n": "llm.prompt_tokens",
|
||||
"data_field": "prompt_tokens",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "completion_tokens",
|
||||
"title_i18n": "llm.completion_tokens",
|
||||
"data_field": "completion_tokens",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unique_chats",
|
||||
"title_i18n": "llm.unique_chats",
|
||||
"data_field": "unique_chats",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "avg_ms",
|
||||
"title_i18n": "llm.avg_ms",
|
||||
"data_field": "avg_ms",
|
||||
"sortable": true,
|
||||
"class": [
|
||||
"text-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "token_share",
|
||||
"title_i18n": "llm.token_share",
|
||||
"data_field": "total_tokens",
|
||||
"sortable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -27,12 +27,9 @@
|
|||
},
|
||||
"homepage": "https://github.com/ntop/ntopng#readme",
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@rollup/plugin-babel": "^5.3.1",
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"css-minify": "^2.0.0",
|
||||
"cubism": "^1.6.0",
|
||||
|
|
@ -45,13 +42,11 @@
|
|||
"postcss": "^8.5.2",
|
||||
"postcss-cli": "^9.1.0",
|
||||
"postcss-scss": "^4.0.3",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"sass": "^1.85.0",
|
||||
"sharp": "^0.34.5",
|
||||
"stylelint": "^14.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.18.2",
|
||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
|
|
|
|||
|
|
@ -7113,7 +7113,12 @@ local lang = {
|
|||
["note_see_both_network_entries"] = "You will see both network entries in the above table.",
|
||||
},
|
||||
["llm"] = {
|
||||
["user"] = "User",
|
||||
["all_users"] = "All Users",
|
||||
["model"] = "Model",
|
||||
["all_models"] = "All Models",
|
||||
["provider"] = "Provider",
|
||||
["all_providers"] = "All Providers",
|
||||
["loading_providers"] = "Loading LLM Providers",
|
||||
["no_providers"] = "No LLM Provider Available",
|
||||
["timeout"] = "Timeout",
|
||||
|
|
@ -7133,6 +7138,28 @@ local lang = {
|
|||
["ai_can_make_mistakes"] = "nAnalyst can make mistakes. Always verify critical information independently",
|
||||
["show_evidence"] = "Show Evidence",
|
||||
["hide_evidence"] = "Hide Evidence",
|
||||
["time_range"] = "Time Range",
|
||||
["back_to_chat"] = "Back To Chat",
|
||||
["no_usage_data"] = "No Usage Data Yet",
|
||||
["token_breakdown"] = "Token Breakdown",
|
||||
["prompt_tokens"] = "Prompt Tokens",
|
||||
["completion_tokens"] = "Completion Tokens",
|
||||
["call_type_breakdown"] = "Call Type Breakdown",
|
||||
["usage_by_model"] = "Usage By Model",
|
||||
["usage_by_user"] = "Usage By User",
|
||||
["initial_call"] = "User Question",
|
||||
["tool_followup"] = "Tool Followup",
|
||||
["final_response"] = "Final Response",
|
||||
["retry"] = "Retry",
|
||||
["stat_total_calls"] = "Total LLM Calls",
|
||||
["stat_total_tokens"] = "Total Tokens",
|
||||
["stat_avg_response"] = "Average Response Time",
|
||||
["stat_unique_chats"] = "Unique Chats",
|
||||
["1h"] = "1h",
|
||||
["6h"] = "6h",
|
||||
["24h"] = "24h",
|
||||
["7d"] = "7d",
|
||||
["30d"] = "30d",
|
||||
},
|
||||
["notification_endpoint"] = {
|
||||
["discord"] = {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ local mitre_utils = require("mitre_utils")
|
|||
local auth = require "auth"
|
||||
local exporter_site_utils = nil
|
||||
|
||||
|
||||
local page = _GET["page"]
|
||||
|
||||
-- remove after graph testing
|
||||
|
|
@ -971,8 +972,7 @@ if isEmptyString(page) or page == "overview" then
|
|||
cli_name = cli_name .. ":" .. flow["cli.port"]
|
||||
srv_name = srv_name .. ":" .. flow["srv.port"]
|
||||
end
|
||||
print('<div class="progress"><div class="progress-bar bg-warning" style="width: ' .. cli2srv .. '%;">' .. cli_name ..
|
||||
'</div><div class="progress-bar bg-success" style="width: ' .. (100 - cli2srv) .. '%;">' .. srv_name .. '</div></div>')
|
||||
print(format_utils.createBreakdown(cli2srv, 100 - cli2srv, cli_name, srv_name))
|
||||
print("</td></tr>\n")
|
||||
end
|
||||
|
||||
|
|
@ -1055,9 +1055,7 @@ if isEmptyString(page) or page == "overview" then
|
|||
pctg = 100 - pctg
|
||||
end
|
||||
|
||||
print('<div class="progress"><div class="progress-bar bg-warning" style="width: ' .. pctg .. '%;">' .. pctg .. '% </div>')
|
||||
pctg = 100 - pctg
|
||||
print('<div class="progress-bar bg-success" style="width: ' .. pctg .. '%;">' .. pctg .. '% </div></div>')
|
||||
print(format_utils.createBreakdown(tonumber(pctg), 100 - tonumber(pctg), 'TX', 'RX'))
|
||||
-- print(formatValue(flow.iec104.stats.forward_msgs).." RX / "..formatValue(flow.iec104.stats.reverse_msgs).." TX")
|
||||
print("</td></tr>\n")
|
||||
|
||||
|
|
@ -1096,11 +1094,8 @@ if isEmptyString(page) or page == "overview" then
|
|||
local srv2cli = round(flow["tcp.nw_latency.3wh_server_rtt"], 3)
|
||||
|
||||
print("<tr><th class='colspan-4'>" .. i18n("flow_details.rtt_breakdown") .. "</th><td colspan=2>")
|
||||
print(
|
||||
'<div class="progress"><div class="progress-bar bg-warning" style="width: ' .. (cli2srv * 100 / rtt) .. '%;">' .. cli2srv ..
|
||||
' ms (client)</div>')
|
||||
print('<div class="progress-bar bg-success" style="width: ' .. (srv2cli * 100 / rtt) .. '%;">' .. srv2cli ..
|
||||
' ms (server)</div></div>')
|
||||
local p1 = math.floor(cli2srv * 100 / rtt)
|
||||
print(format_utils.createBreakdown(p1, 100 - p1, 'client', 'server'))
|
||||
print("</td></tr>\n")
|
||||
|
||||
c = interface.getAddressInfo(flow["cli.ip"])
|
||||
|
|
@ -1417,11 +1412,8 @@ if isEmptyString(page) or page == "overview" then
|
|||
|
||||
score_category_network = (score_category_network * 100) / tot
|
||||
score_category_security = 100 - score_category_network
|
||||
|
||||
print('<td><div class="progress"><div class="progress-bar bg-warning" style="width: ' .. score_category_network .. '%;">' ..
|
||||
i18n("flow_details.score_category_network"))
|
||||
print('</div><div class="progress-bar bg-success" style="width: ' .. score_category_security .. '%;">' ..
|
||||
i18n("flow_details.score_category_security") .. '</div></div></td>\n')
|
||||
|
||||
print('<td>' .. format_utils.createBreakdown(score_category_network, score_category_security, i18n("flow_details.score_category_network"), i18n("flow_details.score_category_security")) .. '</td>')
|
||||
print("</tr>\n")
|
||||
end
|
||||
|
||||
|
|
@ -2473,7 +2465,7 @@ if isEmptyString(page) or page == "overview" then
|
|||
$('#srv2cli').html(NtopUtils.addCommas(rsp["srv2cli.packets"])+" Pkts / " + NtopUtils.addCommas(NtopUtils.bytesToVolume(rsp["srv2cli.bytes"])));
|
||||
$('#flow-throughput').html(rsp.throughput);
|
||||
|
||||
if(typeof rsp["c2sOOO"] !== "undefined") {
|
||||
if(` rsp["c2sOOO"] !== "undefined") {
|
||||
$('#c2sOOO').html(NtopUtils.formatPackets(rsp["c2sOOO"]));
|
||||
$('#s2cOOO').html(NtopUtils.formatPackets(rsp["s2cOOO"]));
|
||||
$('#c2slost').html(NtopUtils.formatPackets(rsp["c2slost"]));
|
||||
|
|
|
|||
|
|
@ -419,10 +419,7 @@ for _key, value in ipairs(flows_stats) do -- pairsByValues(vals, funct) do
|
|||
|
||||
if value["bytes"] > 0 then
|
||||
local cli2srv = round((value["cli2srv.bytes"] * 100) / value["bytes"], 0)
|
||||
record["column_breakdown"] =
|
||||
"<div class='progress'><div class='progress-bar bg-warning' style='width: " .. cli2srv ..
|
||||
"%;'>Client</div><div class='progress-bar bg-success' style='width: " .. (100 - cli2srv) ..
|
||||
"%;'>Server</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(cli2srv, 100 - cli2srv, "Client", "Server")
|
||||
end
|
||||
|
||||
local info
|
||||
|
|
|
|||
|
|
@ -394,8 +394,7 @@ for _key, _value in pairsByKeys(vals, funct) do
|
|||
local sent2rcvd = 0
|
||||
if total_bytes > 0 then
|
||||
sent2rcvd = round((value["bytes.sent"] * 100) / total_bytes, 0) or 0
|
||||
record["column_breakdown"] = "<div class='progress'><div class='progress-bar bg-warning' style='width: "
|
||||
.. sent2rcvd .."%;'>Sent</div><div class='progress-bar bg-success' style='width: " .. (100-sent2rcvd) .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
end
|
||||
|
||||
local _, custom_column_key = custom_column_utils.getCustomColumnName()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ package.path = dirs.installdir .. "/scripts/lua/modules/vulnerability_scan/?.lua
|
|||
require "lua_utils"
|
||||
local json = require "dkjson"
|
||||
local custom_column_utils = require "custom_column_utils"
|
||||
local format_utils = require "format_utils"
|
||||
local vs_utils = require "vs_utils"
|
||||
local custom_column = _GET["custom_column"]
|
||||
|
||||
|
|
@ -127,10 +128,7 @@ local function get_host_data(host)
|
|||
if sent2rcvd == nil then
|
||||
sent2rcvd = 0
|
||||
end
|
||||
res["column_breakdown"] =
|
||||
"<div class='progress'><div class='progress-bar bg-warning' style='width: " .. sent2rcvd ..
|
||||
"%;'>Sent</div><div class='progress-bar bg-success' style='width: " .. (100 - sent2rcvd) ..
|
||||
"%;'>Rcvd</div></div>"
|
||||
res["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
|
||||
return res
|
||||
end
|
||||
|
|
|
|||
|
|
@ -240,10 +240,7 @@ local function scoreBreakdown(what)
|
|||
score_category_network = (score_category_network * 100) / tot
|
||||
score_category_security = 100 - score_category_network
|
||||
|
||||
print('<span class="progress w-100 ms-1"><span class="progress-bar bg-warning" style="width: ' .. score_category_network .. '%;">' ..
|
||||
i18n("flow_details.score_category_network"))
|
||||
print('</span><span class="progress-bar bg-success" style="width: ' .. score_category_security .. '%;">' ..
|
||||
i18n("flow_details.score_category_security") .. '</span></span>\n')
|
||||
print(format_utils.createBreakdown(score_category_network, score_category_security, i18n("flow_details.score_category_network"), i18n("flow_details.score_category_security")))
|
||||
else
|
||||
print(" ")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1016,10 +1016,7 @@ print [[</td></tr>]]
|
|||
tx_ratio = (tx * 100 / tot)
|
||||
rx_ratio = (rx * 100 / tot)
|
||||
end
|
||||
print('<td colspan=2><div class="progress"><div class="progress-bar bg-warning" style="width: ' ..
|
||||
tx_ratio .. '%;">' .. i18n("sent") .. '</div>')
|
||||
print('<div class="progress-bar bg-success" style="width: ' .. rx_ratio .. '%;">' .. i18n("received") ..
|
||||
'</div></div></td>')
|
||||
print('<td colspan=2>' .. format_utils.createBreakdown(tx_ratio, rx_ratio, i18n("sent"), i18n("received")) .. '</td>')
|
||||
|
||||
print("</tr>")
|
||||
end
|
||||
|
|
@ -2310,6 +2307,16 @@ function toggle_mirrored_traffic_function_off(){
|
|||
aysHandleForm("#iface_config");
|
||||
</script>]]
|
||||
elseif (page == "internals") then
|
||||
--[[
|
||||
local context = {
|
||||
ifid = interface.getId(),
|
||||
}
|
||||
template.render("pages/vue_page.template", {
|
||||
vue_page_name = "PageInternals",
|
||||
page_context = json.encode(context),
|
||||
})
|
||||
]]
|
||||
|
||||
internals_utils.printInternals(ifid, true --[[ hash tables ]], true --[[ periodic activities ]], true --[[ checks]],
|
||||
true --[[ queues --]])
|
||||
print [[
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
require "lua_utils"
|
||||
local format_utils = require "format_utils"
|
||||
|
||||
-- Get from redis the throughput type bps or pps
|
||||
local throughput_type = getThroughputType()
|
||||
|
|
@ -17,8 +18,7 @@ function country2record(ifId, country)
|
|||
record["column_since"] = secondsToTime(now - country["seen.first"] + 1)
|
||||
|
||||
local sent2rcvd = round((country["egress"] * 100) / (country["egress"] + country["ingress"]), 0)
|
||||
record["column_breakdown"] = "<div class='progress'><div class='progress-bar bg-warning' style='width: "
|
||||
.. sent2rcvd .."%;'>Sent</div><div class='progress-bar bg-success' style='width: " .. (100-sent2rcvd) .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
|
||||
if(throughput_type == "pps") then
|
||||
record["column_thpt"] = pktsToSize(country["throughput_pps"])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ local format_utils = {}
|
|||
|
||||
local clock_start = os.clock()
|
||||
|
||||
function format_utils.createBreakdown(percentage1, percentage2, label1, label2)
|
||||
if percentage1 == 0 and percentage2 == 0 then
|
||||
return '<div style="height:8px;background:var(--border-subtle,#e9ecef);border-radius:100px;" data-bs-toggle="tooltip" title="No data available"></div>'
|
||||
end
|
||||
local bars = ''
|
||||
local r1 = percentage2 > 0 and '100px 0 0 100px' or '100px'
|
||||
local r2 = percentage1 > 0 and '0 100px 100px 0' or '100px'
|
||||
|
||||
if percentage1 > 0 then
|
||||
bars = bars .. '<div style="width:' .. math.floor(percentage1) .. '%;height:100%;background:var(--ntop-orange,#FF8F00);border-radius:' .. r1 .. ';transition:width .3s ease;" data-bs-toggle="tooltip" title="' .. label1 .. ': ' .. math.floor(percentage1) .. '%"></div>'
|
||||
end
|
||||
|
||||
if percentage2 > 0 then
|
||||
bars = bars .. '<div style="width:' .. math.floor(percentage2) .. '%;height:100%;background:#0d9488;border-radius:' .. r2 .. ';transition:width .3s ease;" data-bs-toggle="tooltip" title="' .. label2 .. ': ' .. math.floor(percentage2) .. '%"></div>'
|
||||
end
|
||||
|
||||
local dot1 = '<span style="width:6px;height:6px;border-radius:50%;background:var(--ntop-orange,#FF8F00);flex-shrink:0;"></span>'
|
||||
local dot2 = '<span style="width:6px;height:6px;border-radius:50%;background:#0d9488;flex-shrink:0;"></span>'
|
||||
|
||||
local span = 'style="display:inline-flex;align-items:center;gap:3px;font-size:0.7rem;color:var(--ntop-muted-text-color,#37474F);"'
|
||||
local leg1 = percentage1 > 0 and ('<span ' .. span .. '>' .. dot1 .. label1 .. ' ' .. math.floor(percentage1) .. '%</span>') or ''
|
||||
local leg2 = percentage2 > 0 and ('<span ' .. span .. '>' .. dot2 .. label2 .. ' ' .. math.floor(percentage2) .. '%</span>') or ''
|
||||
return '<div style="display:flex;flex-direction:column;gap:3px;min-width:0;">'
|
||||
.. '<div style="height:8px;background:var(--border-color,#dee2e6);border-radius:100px;overflow:hidden;display:flex;">' .. bars .. '</div>'
|
||||
.. '<div style="display:flex;gap:8px;flex-wrap:wrap;">' .. leg1 .. leg2 .. '</div>'
|
||||
.. '</div>'
|
||||
end
|
||||
|
||||
function format_utils.round(num, idp)
|
||||
num = tonumber(num)
|
||||
local res
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ require "db_utils"
|
|||
require "rrd_paths"
|
||||
|
||||
local ts_utils = require("ts_utils")
|
||||
local format_utils = require "format_utils"
|
||||
|
||||
-- ########################################################
|
||||
|
||||
|
|
@ -155,15 +156,7 @@ function graph_utils.breakdownBar(sent, sentLabel, rcvd, rcvdLabel,
|
|||
rcvdLabel
|
||||
end
|
||||
|
||||
print(
|
||||
'<div class="progress"><div class="progress-bar bg-warning" aria-valuenow="' ..
|
||||
sent2rcvd ..
|
||||
'" aria-valuemin="0" aria-valuemax="100" style="width: ' ..
|
||||
sent2rcvd .. '%;">' .. sentLabel)
|
||||
print('</div><div class="progress-bar bg-success" aria-valuenow="' ..
|
||||
(100 - sent2rcvd) ..
|
||||
'" aria-valuemin="0" aria-valuemax="100" style="width: ' ..
|
||||
(100 - sent2rcvd) .. '%;">' .. rcvdLabel .. '</div></div>')
|
||||
print(format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, sentLabel, rcvdLabel))
|
||||
else
|
||||
print(' ')
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ local json = require "dkjson"
|
|||
local dscp_consts = require "dscp_consts"
|
||||
local flow_risk_utils = require "flow_risk_utils"
|
||||
local alert_utils = require "alert_utils"
|
||||
local format_utils = require "format_utils"
|
||||
|
||||
local historical_flow_details_formatter = {}
|
||||
|
||||
|
|
@ -181,9 +182,7 @@ function historical_flow_details_formatter.format_historical_bytes_progress_bar(
|
|||
|
||||
return {
|
||||
name = "",
|
||||
values = {'<div class="progress"><div class="progress-bar bg-warning" style="width: ' .. cli2srv .. '%;">' ..
|
||||
(info.cli_ip.label or '') .. '</div>' .. '<div class="progress-bar bg-success" style="width: ' .. (100 - cli2srv) .. '%;">' ..
|
||||
(info.srv_ip.label or '') .. '</div></div>'}
|
||||
values = {format_utils.createBreakdown(cli2srv, 100 - cli2srv, info.cli_ip.label or '', info.srv_ip.label or '')}
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -911,10 +910,8 @@ local function format_historical_flow_rtt(client_nw_latency, server_nw_latency)
|
|||
local rtt = client_nw_latency_ms + server_nw_latency_ms
|
||||
local cli2srv = round(client_nw_latency_ms, 3)
|
||||
local srv2cli = round(server_nw_latency_ms, 3)
|
||||
local values =
|
||||
'<div class="progress"><div class="progress-bar bg-warning" style="width: ' .. (cli2srv * 100 / rtt) .. '%;">' .. cli2srv ..
|
||||
' ms (client)</div>' .. '<div class="progress-bar bg-success" style="width: ' .. (srv2cli * 100 / rtt) .. '%;">' .. srv2cli ..
|
||||
' ms (server)</div></div>'
|
||||
local percentage1 = math.floor(cli2srv * 100 / rtt)
|
||||
local values = format_utils.createBreakdown(percentage1, 100 - percentage1, 'client', 'server')
|
||||
return {
|
||||
name = i18n("flow_details.rtt_breakdown"),
|
||||
values = {values}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
--
|
||||
require "ntop_utils"
|
||||
require "check_redis_prefs"
|
||||
local format_utils = require "format_utils"
|
||||
local discover = require "discover_utils"
|
||||
|
||||
-- Get from redis the throughput type bps or pps
|
||||
|
|
@ -202,9 +203,7 @@ function mac2record(mac)
|
|||
local total_bytes = mac["bytes.sent"] + mac["bytes.rcvd"]
|
||||
if total_bytes > 0 then
|
||||
local sent2rcvd = round((mac["bytes.sent"] * 100) / total_bytes, 0) or 0
|
||||
record["column_breakdown"] = "<div class='progress'><div class='progress-bar bg-warning' style='width: " ..
|
||||
sent2rcvd .. "%;'>Sent</div><div class='progress-bar bg-success' style='width: " ..
|
||||
(100 - sent2rcvd) .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
end
|
||||
|
||||
if (throughput_type == "pps") then
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ function network_formatter.network2record(ifId, network)
|
|||
record["column_host_score_ratio"] = format_utils.format_high_num_value_for_tables(network, "host_score_ratio")
|
||||
|
||||
local sent2rcvd = round((network["bytes.sent"] * 100) / (network["bytes.sent"] + network["bytes.rcvd"]), 0)
|
||||
record["column_breakdown"] = "<div class='progress'><div class='progress-bar bg-warning' style='width: "
|
||||
.. sent2rcvd .."%;'>Sent</div><div class='progress-bar bg-success' style='width: " .. (100-sent2rcvd) .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
|
||||
if(throughput_type == "pps") then
|
||||
record["column_thpt"] = format_utils.pktsToSize(network["throughput_pps"])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ local dirs = ntop.getDirs()
|
|||
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
|
||||
|
||||
require "lua_utils"
|
||||
local format_utils = require "format_utils"
|
||||
|
||||
-- ########################################################
|
||||
|
||||
|
|
@ -44,8 +45,7 @@ function os_data_utils.os2record(ifId, os)
|
|||
record["column_since"] = secondsToTime(now - os["seen.first"] + 1)
|
||||
|
||||
local sent2rcvd = round((os["bytes.sent"] * 100) / (os["bytes.sent"] + os["bytes.rcvd"]), 0)
|
||||
record["column_breakdown"] = "<div class='progress'><div class='progress-bar bg-warning' style='width: "
|
||||
.. sent2rcvd .."%;'>Sent</div><div class='progress-bar bg-success' style='width: " .. (100-sent2rcvd) .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
|
||||
if(throughput_type == "pps") then
|
||||
record["column_thpt"] = pktsToSize(os["throughput_pps"])
|
||||
|
|
|
|||
|
|
@ -460,11 +460,7 @@ function host_pools:hostpool2record(ifid, pool_id, pool)
|
|||
sent2rcvd_pctg = 0
|
||||
rcvd2sent_pctg = 0
|
||||
end
|
||||
record["column_breakdown"] =
|
||||
"<div class='progress'><div class='progress-bar bg-warning' style='width: " ..
|
||||
sent2rcvd_pctg ..
|
||||
"%;'>Sent</div><div class='progress-bar bg-success' style='width: " ..
|
||||
rcvd2sent_pctg .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd_pctg, rcvd2sent_pctg, "Sent", "Rcvd")
|
||||
|
||||
if (throughput_type == "pps") then
|
||||
record["column_thpt"] = format_utils.pktsToSize(pool["throughput_pps"])
|
||||
|
|
|
|||
|
|
@ -31,11 +31,7 @@ function vlan2record(ifId, vlan)
|
|||
|
||||
local sent2rcvd = round((vlan["bytes.sent"] * 100) /
|
||||
(vlan["bytes.sent"] + vlan["bytes.rcvd"]), 0)
|
||||
record["column_breakdown"] =
|
||||
"<div class='progress'><div class='progress-bar bg-warning' style='width: " ..
|
||||
sent2rcvd ..
|
||||
"%;'>Sent</div><div class='progress-bar bg-success' style='width: " ..
|
||||
(100 - sent2rcvd) .. "%;'>Rcvd</div></div>"
|
||||
record["column_breakdown"] = format_utils.createBreakdown(sent2rcvd, 100 - sent2rcvd, "Sent", "Rcvd")
|
||||
|
||||
if (throughput_type == "pps") then
|
||||
record["column_thpt"] = pktsToSize(vlan["throughput_pps"])
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
--
|
||||
-- (C) 2020 - ntop.org
|
||||
--
|
||||
local dirs = ntop.getDirs()
|
||||
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
|
||||
|
||||
require "lua_utils"
|
||||
require "ntop_utils"
|
||||
|
||||
local page_utils = require "page_utils"
|
||||
local json = require "dkjson"
|
||||
local template_utils = require("template_utils")
|
||||
|
||||
sendHTTPContentTypeHeader('text/html')
|
||||
|
||||
page_utils.print_header_and_set_active_menu_entry(page_utils.menu_entries.nanalyst)
|
||||
|
||||
dofile(dirs.installdir .. "/scripts/lua/inc/menu.lua")
|
||||
|
||||
|
||||
local context = {
|
||||
ifid = interface.getId(),
|
||||
csrf = ntop.getRandomCSRFValue()
|
||||
}
|
||||
|
||||
local json_context = json.encode(context)
|
||||
|
||||
template_utils.render("pages/vue_page.template", {
|
||||
vue_page_name = "Chatbot",
|
||||
page_context = json_context
|
||||
})
|
||||
|
||||
dofile(dirs.installdir .. "/scripts/lua/inc/footer.lua")
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
--
|
||||
-- (C) 2013-24 - ntop.org
|
||||
--
|
||||
local dirs = ntop.getDirs()
|
||||
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
|
||||
package.path = dirs.installdir .. "/pro/scripts/lua/modules/?.lua;" .. package.path
|
||||
package.path = dirs.installdir .. "/pro/scripts/lua/enterprise/modules/?.lua;" .. package.path
|
||||
|
||||
require "lua_utils"
|
||||
local json = require "dkjson"
|
||||
local template_utils = require "template_utils"
|
||||
|
||||
local info = ntop.getInfo()
|
||||
local page_utils = require("page_utils")
|
||||
sendHTTPContentTypeHeader('text/html')
|
||||
|
||||
page_utils.print_header_and_set_active_menu_entry(page_utils.menu_entries.about, {
|
||||
product = info.product
|
||||
})
|
||||
dofile(dirs.installdir .. "/scripts/lua/inc/menu.lua")
|
||||
|
||||
template_utils.render("pages/vue_page.template", {
|
||||
vue_page_name = "PageTest",
|
||||
page_context = json.encode({
|
||||
ifid = interface.getId(),
|
||||
csrf = ntop.getRandomCSRFValue()
|
||||
})
|
||||
})
|
||||
-- print(ntop.getASNameFromId(15169))
|
||||
dofile(dirs.installdir .. "/scripts/lua/inc/footer.lua")
|
||||
|
|
@ -11,8 +11,8 @@ import autoprefixer from 'autoprefixer';
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// This config is used exclusively for watch mode and for build:ntopngjs
|
||||
// Full JS + CSS + images check build.mjs.
|
||||
// This config is used exclusively for watch mode and for build:ntopngjs.
|
||||
// Full JS + CSS + images: see build.mjs.
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isProduction = mode === 'production';
|
||||
|
||||
|
|
@ -24,22 +24,16 @@ export default defineConfig(({ mode }) => {
|
|||
emptyOutDir: false,
|
||||
cssCodeSplit: false, // extract CSS to ntopng.css
|
||||
sourcemap: !isProduction,
|
||||
minify: isProduction ? 'terser' : false,
|
||||
terserOptions: isProduction ? {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
},
|
||||
output: {
|
||||
ecma: 5,
|
||||
},
|
||||
} : undefined,
|
||||
minify: isProduction ? 'esbuild' : false,
|
||||
chunkSizeWarningLimit: 5000,
|
||||
rollupOptions: {
|
||||
plugins: [
|
||||
inject({
|
||||
$: 'jquery',
|
||||
jQuery: 'jquery',
|
||||
moment: 'moment-timezone'
|
||||
moment: 'moment-timezone',
|
||||
include: ['**/*.js', '**/*.ts', '**/*.vue', '**/*.mjs'],
|
||||
exclude: ['**/*.css', '**/*.scss', '**/*.sass'],
|
||||
})
|
||||
],
|
||||
input: { ntopng: resolve(__dirname, 'http_src/ntopng.js') },
|
||||
|
|
@ -51,7 +45,7 @@ export default defineConfig(({ mode }) => {
|
|||
name: 'ntopVue',
|
||||
entryFileNames: '[name].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
const name = assetInfo.name || '';
|
||||
const name = assetInfo.names?.[0] || '';
|
||||
if (/\.(png|gif|svg|jpg|jpeg|ico)$/i.test(name)) {
|
||||
return 'images/[name][extname]';
|
||||
}
|
||||
|
|
@ -84,10 +78,14 @@ export default defineConfig(({ mode }) => {
|
|||
{
|
||||
name: 'rename-css-to-ntopng',
|
||||
closeBundle() {
|
||||
renameSync(
|
||||
resolve(__dirname, 'httpdocs/dist/style.css'),
|
||||
resolve(__dirname, 'httpdocs/dist/ntopng.css')
|
||||
);
|
||||
try {
|
||||
renameSync(
|
||||
resolve(__dirname, 'httpdocs/dist/style.css'),
|
||||
resolve(__dirname, 'httpdocs/dist/ntopng.css')
|
||||
);
|
||||
} catch (_) {
|
||||
// style.css may not exist if only JS changed
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -104,8 +102,7 @@ export default defineConfig(({ mode }) => {
|
|||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// Bootstrap 5.3.x uses legacy @import. Silence those warnings
|
||||
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'mixed-decls'],
|
||||
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'if-function'],
|
||||
}
|
||||
},
|
||||
postcss: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue