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:
GabrieleDeri 2026-03-31 18:14:06 +02:00 committed by GitHub
parent dea00e2082
commit 6801ec1034
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1667 additions and 381 deletions

View file

@ -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: {

View file

@ -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"

View file

@ -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">&nbsp;${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}&nbsp;${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}&nbsp;${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 */

View file

@ -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;
}

View file

@ -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(() => {

View file

@ -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

View file

@ -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>

View file

@ -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,

View 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>

View file

@ -92,7 +92,6 @@ const updateSankeyData = async () => {
/* ************************************** */
const changedOption = (opt) => {
debugger
ntopng_url_manager.set_key_to_url(opt.filter_name, opt.id)
updateSankeyData();
}

View file

@ -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`
}

View file

@ -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>

View file

@ -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>

View file

@ -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

View 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"
]
}
]
}

View 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
}
]
}

View file

@ -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",

View file

@ -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"] = {

View file

@ -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"]));

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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("&nbsp;")
end

View file

@ -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 [[

View file

@ -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"])

View file

@ -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 .. '&nbsp;' .. math.floor(percentage1) .. '%</span>') or ''
local leg2 = percentage2 > 0 and ('<span ' .. span .. '>' .. dot2 .. label2 .. '&nbsp;' .. 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

View file

@ -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('&nbsp;')
end

View file

@ -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}

View file

@ -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

View file

@ -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"])

View file

@ -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"])

View file

@ -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"])

View file

@ -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"])

View file

@ -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")

View file

@ -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")

View file

@ -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: {