🔥 Sync 2026-11-04 02:13:23

This commit is contained in:
github-actions[bot] committed 2026-11-04 02:13:23 +08:00
1 parent e281fb4f73
commit bfc1ef0244
65 files changed
+1112 -468

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=firewall4
PKG_RELEASE:=3
PKG_RELEASE:=4
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL=$(PROJECT_GIT)/project/firewall4.git
@@ -0,0 +1,77 @@
From: coolsnowwolf <coolsnowwolf@gmail.com>
Subject: [PATCH] firewall4: apply the configured TCP congestion control
Apply the LuCI tcpcca setting on service start, restart and reload, and
recognize it as a firewall defaults option. Keep the cubic default used
by the UI and only apply settings after a successful firewall operation.
Signed-off-by: coolsnowwolf <coolsnowwolf@gmail.com>
--- a/root/etc/config/firewall
+++ b/root/etc/config/firewall
@@ -5,6 +5,7 @@
option forward REJECT
# Uncomment this line to disable ipv6 rules
# option disable_ipv6 1
+ option tcpcca cubic
option flow_offloading 1
option flow_offloading_hw 1
option fullcone 1
--- a/root/etc/init.d/firewall
+++ b/root/etc/init.d/firewall
@@ -4,17 +4,34 @@
USE_PROCD=1
QUIET=""
+apply_tcpcca_section() {
+ local tcpcca
+
+ config_get tcpcca "$1" tcpcca cubic
+
+ case "$tcpcca" in
+ bbr|cubic|reno)
+ sysctl -w net.ipv4.tcp_congestion_control="$tcpcca" >/dev/null
+ ;;
+ esac
+}
+
+apply_tcpcca() {
+ config_load firewall
+ config_foreach apply_tcpcca_section defaults
+}
+
service_triggers() {
procd_add_reload_trigger firewall
procd_add_reload_data_trigger firewall
}
restart() {
- fw4 restart
+ fw4 restart && apply_tcpcca
}
start_service() {
- fw4 ${QUIET} start
+ fw4 ${QUIET} start && apply_tcpcca
}
stop_service() {
@@ -22,7 +39,7 @@
}
reload_service() {
- fw4 reload
+ fw4 reload && apply_tcpcca
}
boot() {
--- a/root/usr/share/ucode/fw4.uc
+++ b/root/usr/share/ucode/fw4.uc
@@ -2038,6 +2038,7 @@
flow_offloading: [ "bool", "0" ],
flow_offloading_hw: [ "bool", "0" ],
+ tcpcca: [ "string", "cubic" ],
auto_includes: [ "bool", "1" ]
});
+3 -3
View File
@@ -5,11 +5,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.11.03~77176ff
PKG_RELEASE:=28
PKG_VERSION:=2026.11.03~88d470c
PKG_RELEASE:=29
HONK_SRC_URL:=https://github.com/daeuniverse/honk.git
HONK_SRC_VERSION:=77176ff8e8cc2b75385a7030dc376536c382574a
HONK_SRC_VERSION:=88d470c662e0ee7a3de3e67afc6129f67e66c1bf
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILES:=LICENSE
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-honk
PKG_VERSION:=1.1.4
PKG_RELEASE:=32
PKG_RELEASE:=33
LUCI_TITLE:=LuCI support for honk
LUCI_PKGARCH:=all
+10
View File
@@ -362,3 +362,13 @@ msgstr "Zashboard"
msgid "Saved, but the panel files are not installed yet — download the panel first."
msgstr "配置已保存,但面板文件尚未安装,请先下载面板。"
msgid "This page belongs to the advanced mode. Enable Advanced Settings in the global settings to use it."
msgstr "此页面属于高级模式,请先在全局设置中启用「高级设置」。"
msgid "This page belongs to a web dashboard. Select one in the global settings to use it."
msgstr "此页面属于 Web 管理面板,请先在全局设置中选择面板类型。"
msgid "Open Global Settings"
msgstr "前往全局设置"
@@ -8,6 +8,7 @@
'require dom';
'require view';
'require form';
'require session';
function showNotification(title, children, type, timeout) {
timeout = (timeout != null) ? timeout : 3000;
@@ -332,27 +333,57 @@ function ensureEditorStyles() {
'.honk-status-field { display: inline-flex !important; align-items: center !important; justify-content: flex-start !important; gap: 16px !important; flex-wrap: wrap !important; min-height: 32px !important; }',
'.honk-editor-toolbar { margin-bottom: 6px !important; margin-top: 0 !important; display: flex !important; align-items: center !important; justify-content: flex-start !important; }',
'.cm-format-btn { margin: 0 !important; cursor: pointer !important; }',
'.cbi-value:has(.CodeMirror) { align-items: flex-start !important; }',
'.cbi-value:has(.CodeMirror) > .cbi-value-title { padding-top: 5px !important; flex-grow: 0 !important; }',
// 26rem basis: when the row is narrower, the editor wraps under its title instead of being squeezed beside it
'.cbi-value:has(.CodeMirror) .cbi-value-field { flex: 1 1 26rem !important; min-width: 0 !important; width: auto !important; }',
// The editor is the content of its row: label on top, editor across the
// full width, toolbar above it instead of beside it.
'.honk-code-row { display: block !important; }',
'.honk-code-row > .cbi-value-title { display: block !important; width: auto !important; flex: none !important; padding: 0 0 6px 0 !important; }',
'.honk-code-row > .cbi-value-field { display: block !important; width: auto !important; flex: none !important; }',
'.honk-editor { display: block !important; width: 100% !important; min-width: 0 !important; }',
'.honk-editor > .CodeMirror { width: 100% !important; }',
// Colours: a theme variable when the theme has one, otherwise a palette that
// follows the color-scheme every LuCI theme sets. Without light-dark()
// support the plain declarations above each line are used, and without
// either CodeMirror keeps its own light palette.
'.CodeMirror {',
' border: 1px solid var(--border, var(--hairline, var(--border-color-medium, #ccc))) !important;',
' border: 1px solid var(--border, #d5d9de) !important;',
' border: 1px solid var(--border, light-dark(#d5d9de, #2a2f37)) !important;',
' border-radius: var(--radius-base, 4px);',
' height: auto;',
' min-height: 480px;',
' font-family: var(--font-mono, monospace);',
' font-size: 13px;',
' background: var(--background, var(--control-bg, var(--background-color-high, #ffffff))) !important;',
' color: var(--foreground, var(--text, var(--text-color-highest, inherit))) !important;',
' background: var(--background, var(--background-color-high, #ffffff)) !important;',
' background: var(--background, var(--background-color-high, light-dark(#ffffff, #16181c))) !important;',
' color: var(--foreground, var(--text, var(--text-color-highest, #1f2328))) !important;',
' color: var(--foreground, var(--text, var(--text-color-highest, light-dark(#1f2328, #e6e8ea)))) !important;',
' box-shadow: none;',
'}',
'.CodeMirror-gutters {',
' border-right: 1px solid var(--border, var(--hairline, var(--border-color-medium, #ccc))) !important;',
' border-right: 1px solid var(--border, light-dark(#e3e6ea, #262b33)) !important;',
' background: var(--surface-raised, var(--surface-sunken, var(--background-color-low, #f7f7f7))) !important;',
' background: var(--surface-raised, var(--surface-sunken, var(--background-color-low, light-dark(#f7f7f7, #111317)))) !important;',
'}',
'.CodeMirror-linenumber { color: var(--muted-foreground, var(--text-muted, var(--text-color-low, #888888))) !important; }',
'.CodeMirror-cursor { border-left: 1px solid var(--foreground, var(--text, currentColor)) !important; }'
'.CodeMirror-linenumber { color: var(--muted-foreground, var(--text-muted, #8a919b)) !important; }',
'.CodeMirror-linenumber { color: var(--muted-foreground, var(--text-muted, light-dark(#8a919b, #6b7686))) !important; }',
'.CodeMirror-cursor { border-left: 1px solid var(--foreground, var(--text, light-dark(#1f2328, #e6e8ea))) !important; }',
'.CodeMirror-activeline-background { background: light-dark(#f3f6f9, #1b2027) !important; }',
'div.CodeMirror-selected { background: light-dark(#d9e2ec, #2b3440) !important; }',
'.CodeMirror-focused div.CodeMirror-selected { background: light-dark(#b9cfe7, #33414f) !important; }',
'.cm-s-default .cm-comment { color: light-dark(#a50, #7b8794) !important; }',
'.cm-s-default .cm-keyword { color: light-dark(#708, #c792ea) !important; }',
'.cm-s-default .cm-string { color: light-dark(#a11, #7ec699) !important; }',
'.cm-s-default .cm-number { color: light-dark(#164, #f78c6c) !important; }',
'.cm-s-default .cm-def { color: light-dark(#00f, #82aaff) !important; }',
'.cm-s-default .cm-variable { color: light-dark(#1f2328, #e6e8ea) !important; }',
'.cm-s-default .cm-variable-2 { color: light-dark(#05a, #82aaff) !important; }',
'.cm-s-default .cm-variable-3, .cm-s-default .cm-type { color: light-dark(#085, #7fdbca) !important; }',
'.cm-s-default .cm-operator { color: light-dark(#1f2328, #a8b3c2) !important; }',
'.cm-s-default .cm-bracket { color: light-dark(#997, #8b98a9) !important; }',
'.cm-s-default .cm-meta, .cm-s-default .cm-qualifier { color: light-dark(#555, #8b98a9) !important; }',
'.cm-s-default .cm-builtin { color: light-dark(#30a, #c792ea) !important; }',
'.cm-s-default .cm-tag { color: light-dark(#170, #7ec699) !important; }',
'.cm-s-default .cm-attribute { color: light-dark(#00c, #82aaff) !important; }',
'.CodeMirror-foldmarker { color: light-dark(#1f2328, #e6e8ea) !important; background: light-dark(#eef2f6, #262b33) !important; }'
].join('\n');
document.head.appendChild(style);
}
@@ -599,10 +630,21 @@ function initCodeMirror(textarea, onSaveCallback) {
var toolbar = E('div', { 'class': 'honk-editor-toolbar' }, [ formatBtn ]);
// Toolbar and editor share one full width box: some themes lay the form
// field out as a flex row, which would otherwise squeeze the editor into
// the space left of the button instead of using the width of the row.
var wrapper = editor.getWrapperElement();
if (!wrapper.previousElementSibling || !wrapper.previousElementSibling.classList.contains('honk-editor-toolbar')) {
wrapper.parentNode.insertBefore(toolbar, wrapper);
}
var holder = E('div', { 'class': 'honk-editor' });
wrapper.parentNode.insertBefore(holder, wrapper);
holder.appendChild(toolbar);
holder.appendChild(wrapper);
var row = textarea.closest('.cbi-value');
if (row)
row.classList.add('honk-code-row');
editor.refresh();
if (window.IntersectionObserver) {
var observer = new IntersectionObserver(function(entries) {
@@ -627,6 +669,26 @@ function initCodeMirror(textarea, onSaveCallback) {
});
}
// A page that is filtered out of the menu can still be opened by its URL; say
// why it is unavailable instead of rendering an editor that does not apply.
function renderHiddenPage(title, reason) {
var hint = (reason === 'advanced')
? _('This page belongs to the advanced mode. Enable Advanced Settings in the global settings to use it.')
: _('This page belongs to a web dashboard. Select one in the global settings to use it.');
return E('div', { 'class': 'cbi-map' }, [
E('style', {}, '.cbi-page-actions { display: none !important; }'),
E('h2', {}, title),
E('div', { 'class': 'cbi-section' }, [
E('p', {}, hint),
E('p', {}, E('a', {
'class': 'btn cbi-button cbi-button-action',
'href': L.url('admin', 'services', 'honk', 'global')
}, _('Open Global Settings')))
])
]);
}
function createConfigFileView(filePath, mapTitle, mapDesc, fieldTitle, successMsg, needRestart) {
return view.extend({
load: function() {
@@ -634,6 +696,11 @@ function createConfigFileView(filePath, mapTitle, mapDesc, fieldTitle, successMs
},
render: function() {
var reason = hiddenTabReason(currentTab());
if (reason)
return renderHiddenPage(mapTitle, reason);
var m = new form.Map('honk', mapTitle, mapDesc);
var s = m.section(form.NamedSection, '_status');
@@ -700,73 +767,174 @@ function createConfigFileView(filePath, mapTitle, mapDesc, fieldTitle, successMs
}
function updateTabVisibilityFromSections(sections) {
var s = (sections && sections[0]) ? sections[0] : (uci.get_first('honk', 'honk') || {});
var isAdvanced = (s.advanced === '1');
var dashType = s.dashboard || '';
// ---------------------------------------------------------------------------
// Pages that do not apply to the current settings
//
// The DNS, node and routing editors belong to advanced mode, the API editor to
// a selected dashboard. menu.d cannot express that: its depends.uci matches
// option existence only, and the evaluated menu is cached in
// /tmp/luci-indexcache. The entries are filtered out here instead - in the menu
// tree every theme builds its navigation and page tabs from, plus the links of
// a bar a theme has already painted, matched by their target instead of by
// theme specific classes.
// ---------------------------------------------------------------------------
var hiddenTabs = [];
if (!isAdvanced) {
hiddenTabs.push('dns', 'node', 'route');
}
if (dashType === 'none') {
hiddenTabs.push('api');
}
var ADVANCED_TABS = [ 'dns', 'node', 'route' ];
var MENU_BASE_KEY = 'honk-menu-base';
var MENU_STATE_KEY = 'honk-menu-state';
applyTabCss(hiddenTabs);
var currentTab = (window.L && L.env && Array.isArray(L.env.dispatchpath)) ? L.env.dispatchpath[3] : '';
if (!currentTab) {
var m = window.location.pathname.match(/\/honk\/([a-z0-9_-]+)/);
if (m) currentTab = m[1];
}
if (currentTab && hiddenTabs.indexOf(currentTab) !== -1) {
window.location.href = L.url('admin/services/honk/global');
}
function honkSettings() {
return uci.get_first('honk', 'honk') || {};
}
// Tab visibility control:
// - dns, node, route are hidden when advanced=0
// - api is hidden when dashboard=none
function applyTabVisibility() {
var sections = uci.sections('honk', 'honk');
if (sections && sections.length) {
updateTabVisibilityFromSections(sections);
return Promise.resolve();
}
function getHiddenTabs() {
var settings = honkSettings();
var hidden = [];
if (settings.advanced !== '1')
hidden = hidden.concat(ADVANCED_TABS);
if (settings.dashboard === 'none')
hidden.push('api');
return hidden;
}
// The setting that keeps a page hidden, or null when the page applies.
function hiddenTabReason(tab) {
var settings = honkSettings();
if (tab && ADVANCED_TABS.indexOf(tab) !== -1 && settings.advanced !== '1')
return 'advanced';
if (tab === 'api' && settings.dashboard === 'none')
return 'dashboard';
return null;
}
function currentTab() {
var dispatch = (window.L && L.env && Array.isArray(L.env.dispatchpath)) ? L.env.dispatchpath : [];
var fromUrl = window.location.pathname.match(/\/honk\/([a-z0-9_-]+)/);
return (dispatch.length > 3) ? dispatch[3] : (fromUrl ? fromUrl[1] : '');
}
function honkMenuNode(tree) {
var node = tree;
[ 'admin', 'services', 'honk' ].forEach(function(name) {
node = (node && node.children) ? node.children[name] : null;
});
return node || null;
}
// Filter the shared menu tree, and keep the filtered copy in LuCI's session
// cache so that the following page loads paint the same navigation.
function hideMenuEntries(hidden) {
var state = hidden.join(',');
return ui.menu.load().then(function(tree) {
if (!L.isObject(tree) || !L.isObject(tree.children))
return;
var cached = session.getLocalData('menu');
var base = session.getLocalData(MENU_BASE_KEY);
// Keep the unfiltered tree, so that entries come back when the setting
// that hid them is enabled again. LuCI drops its own copy when packages
// change, and the tree fetched then is the new reference.
if (!L.isObject(cached) || !L.isObject(base)) {
base = JSON.parse(JSON.stringify(tree));
session.setLocalData(MENU_BASE_KEY, base);
}
if (L.isObject(cached) && session.getLocalData(MENU_STATE_KEY) === state)
return;
var filtered = JSON.parse(JSON.stringify(base));
var honk = honkMenuNode(filtered);
if (honk && L.isObject(honk.children)) {
hidden.forEach(function(name) {
if (honk.children[name])
honk.children[name].satisfied = false;
});
}
ui.menu.menu = filtered;
session.setLocalData('menu', filtered);
session.setLocalData(MENU_STATE_KEY, state);
}).catch(function() {});
}
function hideTabLinks(hidden) {
if (!hidden.length)
return;
var targets = hidden.map(function(name) {
return L.url('admin', 'services', 'honk', name);
});
document.querySelectorAll('a[href]').forEach(function(link) {
if (link.closest('#view') || targets.indexOf(link.pathname) === -1)
return;
(link.closest('li') || link).style.setProperty('display', 'none', 'important');
});
}
var lateLinks = null;
// Themes paint their navigation once the menu promise settles - after this
// module may have been evaluated - and some of them rebuild parts of it when it
// is opened (the vantage flyout does), so keep watching for those links.
function watchLateLinks() {
if (lateLinks || !window.MutationObserver)
return;
var queued = false;
lateLinks = new MutationObserver(function() {
if (queued)
return;
queued = true;
window.setTimeout(function() {
queued = false;
hideTabLinks(getHiddenTabs());
}, 100);
});
lateLinks.observe(document.body, { childList: true, subtree: true });
}
// dns, node and route are hidden when advanced=0, api when dashboard=none.
function applyTabVisibility() {
return uci.load('honk').then(function() {
updateTabVisibilityFromSections(uci.sections('honk', 'honk'));
var hidden = getHiddenTabs();
hideTabLinks(hidden);
watchLateLinks();
return hideMenuEntries(hidden);
}).catch(function() {
});
}
function applyTabCss(hiddenTabs) {
var styleId = 'honk-tab-visibility-style';
var existing = document.getElementById(styleId);
if (!existing) {
existing = document.createElement('style');
existing.id = styleId;
document.head.appendChild(existing);
}
if (!hiddenTabs || hiddenTabs.length === 0) {
existing.textContent = '';
} else {
existing.textContent = hiddenTabs.map(function(tab) {
return '#tabmenu .tabmenu-item-' + tab + ',\n' +
'.tabmenu-item-' + tab + ',\n' +
'#tabmenu a[href$="/honk/' + tab + '"]';
}).join(',\n') + ' { display: none !important; }';
}
// The service changes the stored settings on its own when the dashboard is
// switched, so re-read them before the page picks the menu it paints from.
function refreshTabVisibility() {
uci.unload('honk');
return applyTabVisibility();
}
// Early synchronous check if UCI data is already loaded in memory
try {
var _earlySections = uci.sections('honk', 'honk');
if (_earlySections && _earlySections.length) {
updateTabVisibilityFromSections(_earlySections);
}
} catch (e) {}
// Start right away: the theme's navigation may still be on its way.
applyTabVisibility();
function renderStatusHeader() {
var statusEl = E('span', { 'id': 'honk_status', 'style': 'font-weight: 500;' }, [
@@ -815,8 +983,6 @@ function renderStatusHeader() {
addElementPoll(pollStatusFn, statusEl, 5);
applyTabVisibility();
return section;
}
@@ -824,6 +990,7 @@ preloadCodeMirror();
return baseclass.extend({
applyTabVisibility: applyTabVisibility,
refreshTabVisibility: refreshTabVisibility,
callHonkStatus: callHonkStatus,
callHonkReload: callHonkReload,
callHonkRestart: callHonkRestart,
@@ -17,9 +17,6 @@ return view.extend({
},
render: function() {
if (honk && honk.applyTabVisibility) {
honk.applyTabVisibility();
}
var sec = (uci.sections('honk', 'honk')[0] || {});
var sid = sec['.name'] || 'config';
var dashType = uci.get('honk', sid, 'dashboard') || 'none';
@@ -43,7 +40,11 @@ return view.extend({
return;
}
window.location.reload();
// The switch changes the stored settings, and the reloaded page has
// to paint the tabs that apply to the new dashboard.
honk.refreshTabVisibility().then(function() {
window.location.reload();
});
}).catch(function(err) {
sel.disabled = false;
honk.showNotification(null, E('p', _('Failed to switch dashboard:') + ' ' + (err.message || err)), 'error');
@@ -63,6 +63,10 @@ return view.extend({
return this.handleSave(ev).then(function() {
var newDash = uci.get('honk', sid, 'dashboard') || 'none';
// The menu the reloaded page paints from has to match the new settings,
// and LuCI reloads before the view renders again.
honk.applyTabVisibility();
var pre = (newDash !== oldDash) ? honk.switchDashboardAndWait(newDash).then(function(res) {
if (res && res.pending)
honk.showNotification(null, E('p', _('Saved, but the panel files are not installed yet — download the panel first.')), 'warning');
@@ -14,10 +14,6 @@ return view.extend({
handleReset: null,
render: function() {
if (honk && honk.applyTabVisibility) {
honk.applyTabVisibility();
}
var scrolled = false;
var logTextarea = E('textarea', {
+2 -2
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.29
PKG_RELEASE:=364
PKG_VERSION:=26.11.3
PKG_RELEASE:=365
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.29
PKG_RELEASE:=165
PKG_VERSION:=26.11.3
PKG_RELEASE:=166
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-trafficctl
PKG_VERSION:=1.25.4
PKG_RELEASE:=19
PKG_VERSION:=1.26.1
PKG_RELEASE:=20
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
@@ -32,6 +32,13 @@ var callHistoryRead = rpc.declare({
expect: { result: [] }
});
var callHistoryReadIfaces = rpc.declare({
object: 'luci.trafficctl',
method: 'history_read_ifaces',
params: ['period', 'to'],
expect: { result: [] }
});
var callHistoryFlush = rpc.declare({
object: 'luci.trafficctl',
method: 'history_flush',
@@ -86,6 +93,35 @@ var ACCURACY = {
}
};
// Deliberately separate from ACCURACY. The interface series is not a view of
// the device series and its accuracy does not follow it: these bytes come from
// the kernel's own interface counters via /proc/net/dev, so flow offloading
// does not hide them. Under offload this table is the MORE trustworthy of the
// two, which is the inverse of the device table's verdict — inheriting it
// would be wrong in the direction that matters.
var IF_ACCURACY = {
'exact': {
cls: 'tc-c-ok',
icon: '●',
text: _('Exact. These are the router\'s own interface counters, so flow offloading does not undercount them — with offloading on, this table is more trustworthy than the device table above. A link that goes down restarts its counter: the traffic it carried between the last sample and the restart is lost.')
}
};
function ifAccuracyOf(verdict) {
return IF_ACCURACY[verdict] || {
cls: 'tc-c-warn',
icon: '⚠',
text: _('The accuracy of these interface figures could not be determined.')
};
}
// '-' and '?' are the store's placeholders for an absent label and an unknown
// role (the file is tab-separated, so an empty field could not be empty).
function ifText(v) {
if (!v || v === '-' || v === '?') return '';
return v;
}
function accuracyOf(verdict) {
return ACCURACY[verdict] || {
cls: 'tc-c-warn',
@@ -102,6 +138,10 @@ return view.extend({
selTo: '',
sortKey: 'total',
tbody: null,
ifRows: [],
ifBody: null,
ifNoteEl: null,
ifWrapEl: null,
statusEl: null,
bannerEl: null,
@@ -227,16 +267,100 @@ return view.extend({
]));
},
// Fixed order rather than a sortable header: there are a handful of rows
// and the useful reading is "which WAN carried what", so WAN first.
ifSorted: function() {
var rank = { wan: 0, vpn: 1, lan: 2, other: 3 };
return this.ifRows.slice().sort(function(a, b) {
var ra = rank[a.role], rb = rank[b.role];
if (ra === undefined) ra = 4;
if (rb === undefined) rb = 4;
if (ra !== rb) return ra - rb;
return ((b.bytes_in || 0) + (b.bytes_out || 0)) -
((a.bytes_in || 0) + (a.bytes_out || 0));
});
},
renderIfRow: function(r) {
var label = ifText(r.label) || r.dev;
var role = ifText(r.role);
var name = [E('span', { 'class': 'tc-fw-bold' }, label)];
// The device name is what appears in logs and in `ip link`, so it is
// worth showing even when a friendlier label exists.
if (label !== r.dev) {
name.push(E('span', { 'class': 'tc-mono tc-sm tc-c-faint',
'style': 'margin-left:6px' }, r.dev));
}
if (role) {
name.push(E('span', { 'class': 'tc-c-muted tc-sm',
'style': 'margin-left:6px' }, role));
}
var tot = (r.bytes_in || 0) + (r.bytes_out || 0);
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, name),
E('td', { 'class': 'td tc-right tc-mono tc-sm' }, fmtBytes(r.bytes_in || 0)),
E('td', { 'class': 'td tc-right tc-mono tc-sm' }, fmtBytes(r.bytes_out || 0)),
E('td', { 'class': 'td tc-right tc-mono tc-fw-bold' }, fmtBytes(tot)),
E('td', { 'class': 'td tc-center tc-sm tc-c-muted' }, fmtAgo(r.last_seen))
]);
},
// No total row, unlike the device table. These rows overlap by design: a
// VPN tunnel's bytes also travelled over its WAN, and the LAN bridge sees
// the same traffic from the other side. A sum would be a number with no
// meaning, presented as if it were the period's total.
renderIfTable: function() {
var self = this;
while (this.ifBody.firstChild) this.ifBody.removeChild(this.ifBody.firstChild);
if (!this.ifRows.length) {
this.ifBody.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td tc-c-faint', 'colspan': 5 },
_('No interface traffic recorded for this period yet.'))
]));
return;
}
this.ifSorted().forEach(function(r) {
self.ifBody.appendChild(self.renderIfRow(r));
});
},
// Hidden entirely when the series is off or history is: an empty table
// with a note about accuracy would be worse than no table.
renderIfSection: function() {
if (!this.ifWrapEl) return;
var on = !!this.st.enabled && this.st.interfaces !== false;
this.ifWrapEl.classList.toggle('tc-hidden', !on);
if (!on) return;
var a = ifAccuracyOf(this.st.iface_accuracy);
while (this.ifNoteEl.firstChild) this.ifNoteEl.removeChild(this.ifNoteEl.firstChild);
this.ifNoteEl.appendChild(E('div', { 'class': a.cls }, [
E('span', { 'class': 'tc-fw-bold' }, a.icon + ' '),
document.createTextNode(a.text)
]));
},
loadPeriod: function() {
var self = this;
self.statusEl.textContent = _('Loading…');
return callHistoryRead(self.sel || '', self.selTo || '').then(function(rows) {
self.rows = rows || [];
// Each series catches its own failure: one of them being unreadable
// must not blank the other, since they come from different files.
var wantIf = self.st.interfaces !== false;
return Promise.all([
callHistoryRead(self.sel || '', self.selTo || '')
.then(function(rows) { self.rows = rows || []; })
.catch(function(e) {
self.rows = [];
ui.addNotification(null, E('p', e.message), 'error');
}),
wantIf ? callHistoryReadIfaces(self.sel || '', self.selTo || '')
.then(function(rows) { self.ifRows = rows || []; })
.catch(function() { self.ifRows = []; })
: Promise.resolve()
]).then(function() {
self.statusEl.textContent = '';
self.renderTable();
}).catch(function(e) {
self.statusEl.textContent = '';
ui.addNotification(null, E('p', e.message), 'error');
self.renderIfTable();
self.renderIfSection();
});
},
@@ -247,6 +371,7 @@ return view.extend({
self.st = res[0] || {};
self.periods = res[1] || [];
self.renderBanner();
self.renderIfSection();
}).catch(function() {});
},
@@ -405,8 +530,36 @@ return view.extend({
var table = E('table', { 'class': 'table tc-table' }, [thead, this.tbody]);
// ── interfaces: a second series, below the first ────────────────────
this.ifBody = E('tbody', {});
this.ifNoteEl = E('div', { 'style': 'margin-bottom:6px' });
this.ifWrapEl = E('div', { 'class': 'tc-hidden', 'style': 'margin-top:18px' }, [
E('h3', {}, _('Per-interface totals')),
E('div', { 'class': 'cbi-map-descr' },
_('Which link carried the traffic — the question the device table cannot answer. This is a separate series, not a breakdown of it: the rows include the router\'s own traffic and broadcast, and they overlap each other (a VPN tunnel\'s bytes also travelled over its WAN), so the two tables are not meant to add up. Received and Sent are relative to the interface, so on the LAN bridge Received is what your devices sent.')),
this.ifNoteEl,
E('table', { 'class': 'table tc-table' }, [
E('thead', {}, [
E('tr', { 'class': 'tr cbi-section-table-titles' }, [
E('th', { 'class': 'th' }, _('Interface')),
E('th', { 'class': 'th tc-right',
'title': _('Bytes the interface received. On a WAN that is download; on the LAN bridge it is what your devices uploaded.') },
_('Received')),
E('th', { 'class': 'th tc-right',
'title': _('Bytes the interface sent. On a WAN that is upload; on the LAN bridge it is what your devices downloaded.') },
_('Sent')),
E('th', { 'class': 'th tc-right' }, _('Total')),
E('th', { 'class': 'th tc-center' }, _('Last active'))
])
]),
this.ifBody
])
]);
this.renderBanner();
this.renderTable();
this.renderIfTable();
this.renderIfSection();
if (this.st.enabled) this.loadPeriod();
// ── controls ────────────────────────────────────────────────────────
@@ -425,7 +578,10 @@ return view.extend({
enableBtn.textContent = self.st.enabled ? _('Switch off') : _('Switch on');
if (self.st.enabled) return self.loadPeriod();
self.rows = [];
self.ifRows = [];
self.renderTable();
self.renderIfTable();
self.renderIfSection();
});
}).catch(function(e) {
enableBtn.disabled = false;
@@ -468,7 +624,8 @@ return view.extend({
this.bannerEl,
E('div', { 'style': 'margin-bottom:8px' }, [enableBtn, flushBtn, csvBtn]),
picker,
table
table,
this.ifWrapEl
]);
}
});
@@ -1113,6 +1113,18 @@ function buildGroupedTable(groups, sortCol, sortDir) {
return E('div', { 'class': 'table tc-table' }, [titleRow].concat(rows));
}
// 'oif' is the uci interface name when the router has one, 'oif_dev' the
// kernel device it resolved to. Same layout as the overview rows: the device
// is only worth showing when it differs from the name (#10).
function oifCell(r) {
if (!r.oif) return E('div', { 'class': 'td tc-mono' }, E('span', { 'class': 'tc-c-faint' }, '—'));
var cell = E('div', { 'class': 'td tc-mono' }, escHtml(r.oif));
if (r.oif_dev && r.oif_dev !== r.oif) {
cell.appendChild(E('span', { 'class': 'tc-c-faint tc-ov-ifdev' }, ' ' + escHtml(r.oif_dev)));
}
return cell;
}
function buildTable(conns, sortCol, sortDir, rdnsMode, hiddenCols) {
var allCols = [
{ key:'proto', label: _('Proto'), num:false },
@@ -1166,7 +1178,7 @@ function buildTable(conns, sortCol, sortDir, rdnsMode, hiddenCols) {
service: E('div', { 'class': 'td tc-c-speed' }, escHtml(r.service || (SERVICE_PORTS[r.port]||''))),
bytes: E('div', { 'class': 'td tc-right tc-mono tc-fw-bold' }, fmtBytes(r.bytes)),
state: E('div', { 'class': 'td tc-fw-bold' + scCls }, state),
oif: E('div', { 'class': 'td tc-mono' }, r.oif ? escHtml(r.oif) : E('span', { 'class': 'tc-c-faint' }, '—'))
oif: oifCell(r)
};
var cells = cols.map(function(c) { return cellMap[c.key]; });
return E('div', { 'class': 'tr' }, cells);
@@ -181,6 +181,12 @@ msgstr ""
msgid "Bytes sent back to remote clients"
msgstr ""
msgid "Bytes the interface received. On a WAN that is download; on the LAN bridge it is what your devices uploaded."
msgstr ""
msgid "Bytes the interface sent. On a WAN that is upload; on the LAN bridge it is what your devices downloaded."
msgstr ""
msgid "CPU load (1 min)"
msgstr ""
@@ -352,6 +358,9 @@ msgstr ""
msgid "Exact. Byte counts come from cumulative nftables counters, so they do not depend on how often the sampler runs. The TCP/UDP split is unavailable from this source."
msgstr ""
msgid "Exact. These are the router's own interface counters, so flow offloading does not undercount them — with offloading on, this table is more trustworthy than the device table above. A link that goes down restarts its counter: the traffic it carried between the last sample and the restart is lost."
msgstr ""
msgid "Export CSV"
msgstr ""
@@ -484,6 +493,9 @@ msgstr ""
msgid "Inline keyboard preview:"
msgstr ""
msgid "Interface"
msgstr ""
msgid "Interfaces"
msgstr ""
@@ -508,6 +520,9 @@ msgstr ""
msgid "LAN keeps working"
msgstr ""
msgid "Last active"
msgstr ""
msgid "Last seen"
msgstr ""
@@ -622,6 +637,9 @@ msgstr ""
msgid "No extended stats available."
msgstr ""
msgid "No interface traffic recorded for this period yet."
msgstr ""
msgid "No offload"
msgstr ""
@@ -721,6 +739,9 @@ msgstr ""
msgid "Per-device totals accumulated per accounting period, including devices that are not connected now. Figures survive reboots; the live view on the Devices tab does not."
msgstr ""
msgid "Per-interface totals"
msgstr ""
msgid "Period"
msgstr ""
@@ -781,6 +802,9 @@ msgstr ""
msgid "Ratelimits"
msgstr ""
msgid "Received"
msgstr ""
msgid "Received by this interface"
msgstr ""
@@ -850,6 +874,9 @@ msgstr ""
msgid "Sending…"
msgstr ""
msgid "Sent"
msgstr ""
msgid "Sent by this interface"
msgstr ""
@@ -994,6 +1021,9 @@ msgstr ""
msgid "The accuracy of these figures could not be determined."
msgstr ""
msgid "The accuracy of these interface figures could not be determined."
msgstr ""
msgid "The auto-revert helper is not running; this page re-checks the deadline while it is open."
msgstr ""
@@ -1132,6 +1162,9 @@ msgstr ""
msgid "When this device last moved any traffic, not when it was last connected"
msgstr ""
msgid "Which link carried the traffic — the question the device table cannot answer. This is a separate series, not a breakdown of it: the rows include the router's own traffic and broadcast, and they overlap each other (a VPN tunnel's bytes also travelled over its WAN), so the two tables are not meant to add up. Received and Sent are relative to the interface, so on the LAN bridge Received is what your devices sent."
msgstr ""
msgid "While this total was being accumulated, flow offloading froze the counters it is built from. The figure is a floor, not a measurement."
msgstr ""
@@ -279,11 +279,29 @@ if command -v ip >/dev/null 2>&1; then
done
fi
CONNS_OUT=$(echo "$CONNTRACK_DATA" | awk -v ip="$IP" -v pf="$PROTO_FILTER" -v oifmap="$OIF_MAP" '
# The kernel device is not what the user named the interface: mwan3 egress is
# "awg3", the network is "Finland" (#10). The uci name comes from the role map
# trafficctl-ifaces.sh already memoizes in tmpfs — the overview polls it, so it
# is normally warm. Built here only when it is missing and there is something
# to label, never per connection.
IFROLE_CACHE="${TCTL_IFROLE_CACHE:-/tmp/trafficctl_ifroles}"
if [ -s "$OIF_MAP" ] && [ ! -s "$IFROLE_CACHE" ]; then
/usr/local/bin/trafficctl-ifaces.sh >/dev/null 2>&1
fi
CONNS_OUT=$(echo "$CONNTRACK_DATA" | awk -v ip="$IP" -v pf="$PROTO_FILTER" -v oifmap="$OIF_MAP" -v ifroles="$IFROLE_CACHE" '
BEGIN {
n=0
while ((getline line < oifmap) > 0) { split(line, p, " "); oifdev[p[1]] = p[2] }
close(oifmap)
# "dev role tunnel primary defroute enslaved label", label "-" when the
# device has no uci interface. Header lines start with "#".
while ((getline line < ifroles) > 0) {
if (substr(line, 1, 1) == "#") continue
if (split(line, f, " ") < 7 || f[7] == "-") continue
oiflbl[f[1]] = f[7]
}
close(ifroles)
}
{
proto=""
@@ -325,10 +343,11 @@ BEGIN {
else if (dport == "993") svc="imaps"
else if (dport == "8080") svc="http-alt"
oif = ""
if (dst in oifdev) oif = oifdev[dst]
odev = ""; oif = ""
if (dst in oifdev) { odev = oifdev[dst]; oif = odev }
if (odev in oiflbl) oif = oiflbl[odev]
if (n > 0) printf ","
printf "{\"proto\":\"%s\",\"dst\":\"%s\",\"host\":\"\",\"port\":%s,\"service\":\"%s\",\"bytes\":%.0f,\"state\":\"%s\",\"oif\":\"%s\"}", proto, dst, dport, svc, bytes, state, oif
printf "{\"proto\":\"%s\",\"dst\":\"%s\",\"host\":\"\",\"port\":%s,\"service\":\"%s\",\"bytes\":%.0f,\"state\":\"%s\",\"oif\":\"%s\",\"oif_dev\":\"%s\"}", proto, dst, dport, svc, bytes, state, oif, odev
n++
}
')
@@ -129,7 +129,7 @@ tctl_ratelimit_add() {
local rate_kbyte_up=$((rate_kbit_up / 8))
[ "$rate_kbyte_up" -lt 1 ] && rate_kbyte_up=1
local slug dl_expr ul_expr ul6_expr mac
local slug dl_expr ul_expr ul6_expr dl6_expr feed6_expr v6set mac
slug=$(tctl_target_slug "$ip")
# The two directions were already separate rules on separate hooks — they
# were symmetric only because both took the same number.
@@ -141,12 +141,9 @@ tctl_ratelimit_add() {
ul_expr="ip saddr $ip limit rate over ${rate_kbyte_up} kbytes/second"
fi
# Upload over IPv6, keyed on the MAC. This direction and only this one:
# at LAN ingress the frame is still the client's own, so "ether saddr"
# identifies it whatever address family it is using. Download over IPv6 is
# policed at LAN egress, where the destination MAC is the next hop's — it
# needs the device's current v6 address set and is deliberately not
# attempted here.
# Upload over IPv6, keyed on the MAC: at LAN ingress the frame is still
# the client's own, so "ether saddr" identifies it whatever address family
# it is using.
#
# Scoped to IPv6 on purpose: IPv4 keeps being policed by exactly the rule it
# always was, so nothing about existing behaviour depends on whether this
@@ -161,13 +158,41 @@ tctl_ratelimit_add() {
# against nftables 1.1.1 / kernel 6.6). nfproto is what tctl_block_add uses,
# because that rule lives in inet fw4. Same intent, different family, and
# the wrong one does not silently under-match — it fails to load.
ul6_expr=""
#
# Download over IPv6 cannot be keyed on the MAC: at LAN egress the L2
# header is not built yet. It is matched on the device's v6 ADDRESSES,
# which is only sound if the set of them can never go stale — SLAAC
# privacy extensions rotate them, and a set that misses the current one
# is a silent bypass. So the set is not written from a neighbour-table
# snapshot that something would have to keep refreshing; the data path
# fills it. Every v6 packet the client sends crosses LAN ingress with its
# own MAC, and the feed rule there adds its source address with a timeout
# ("update" re-arms it on every packet, so an address in use never
# expires). A new temporary address is in the set from its first packet,
# one that stopped being used drops out after the timeout. Download that
# no packet from the client ever preceded is the only thing that is not
# covered, and for TCP there is none. Link-local is left out: it never
# crosses the router.
#
# Verified on kernel 6.12 / nftables 1.0.9 with a client in its own netns
# behind a bridge: one ping from a privacy address put that address in the
# set, and a 5 Mbit/s limit held an iperf3 download to it at ~6 Mbit/s.
ul6_expr=""; dl6_expr=""; feed6_expr=""
v6set="tctl_v6_$slug"
if mac=$(tctl_target_mac "$ip"); then
ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte_up} kbytes/second"
feed6_expr="meta protocol ip6 ether saddr $mac ip6 saddr != fe80::/10 update @$v6set { ip6 saddr }"
dl6_expr="meta protocol ip6 ip6 daddr @$v6set limit rate over ${rate_kbyte} kbytes/second"
fi
if [ "$TCTL_FW" = "nft" ]; then
nft add table netdev tm_ratelimit 2>/dev/null
# The set has to exist before any rule that names it. Without it the
# v6 download half is skipped and reported as such, never half-built.
if [ -n "$dl6_expr" ] && ! nft add set netdev tm_ratelimit "$v6set" \
"{ type ipv6_addr; flags dynamic,timeout; timeout ${TCTL_V6SET_TIMEOUT:-1h}; }" 2>/dev/null; then
feed6_expr=""; dl6_expr=""
fi
# Download is policed at LAN EGRESS, not WAN ingress.
#
@@ -183,7 +208,7 @@ tctl_ratelimit_add() {
# the IP stack, and the port-level egress hook never fires for it.
# Verified on kernel 6.12: br-lan egress counted every packet, the
# ports counted none.
local dev chain dl_ok=0 wan_dev
local dev chain dl_ok=0 dl6_ok=0 wan_dev
for dev in $(tctl_get_lan_devices); do
chain=$(tctl_egress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
@@ -191,15 +216,23 @@ tctl_ratelimit_add() {
nft add rule netdev tm_ratelimit "$chain" \
"$dl_expr counter drop comment \"$comment\"" 2>/dev/null \
&& dl_ok=1
[ -n "$dl6_expr" ] || continue
nft add rule netdev tm_ratelimit "$chain" \
"$dl6_expr counter drop comment \"${comment}_dl6\"" 2>/dev/null \
&& dl6_ok=1
done
# Kernels older than 5.16 have no netdev egress hook; fall back to WAN
# ingress, which is correct as long as the client isn't masqueraded.
# IPv6 is not masqueraded at all, so the v6 rule is correct there too.
if [ "$dl_ok" = "0" ] && wan_dev=$(tctl_get_wan_device); then
nft add chain netdev tm_ratelimit dl \
"{ type filter hook ingress device $wan_dev priority -200; policy accept; }" 2>/dev/null
nft add rule netdev tm_ratelimit dl \
"$dl_expr counter drop comment \"$comment\"" 2>/dev/null \
&& dl_ok=1
[ -n "$dl6_expr" ] && nft add rule netdev tm_ratelimit dl \
"$dl6_expr counter drop comment \"${comment}_dl6\"" 2>/dev/null \
&& dl6_ok=1
fi
# Set for the caller to inspect (see trafficctl-ratelimit.sh).
# shellcheck disable=SC2034
@@ -208,7 +241,7 @@ tctl_ratelimit_add() {
# One chain per ingress device: a device that refuses the hook then
# only loses its own chain instead of taking the whole set with it.
local dev chain
local ul_ok=0 ul6_ok=0
local ul_ok=0 ul6_ok=0 feed6_ok=0
for dev in $(tctl_ingress_devices); do
chain=$(tctl_ingress_chain "$dev")
nft add chain netdev tm_ratelimit "$chain" \
@@ -217,6 +250,11 @@ tctl_ratelimit_add() {
"$ul_expr counter drop comment \"${comment}_ul\"" 2>/dev/null \
&& ul_ok=1
[ -n "$ul6_expr" ] || continue
# Feed before police, so a packet the upload limit drops still
# keeps its address in the download set.
[ -n "$feed6_expr" ] && nft add rule netdev tm_ratelimit "$chain" \
"$feed6_expr comment \"${comment}_v6\"" 2>/dev/null \
&& feed6_ok=1
nft add rule netdev tm_ratelimit "$chain" \
"$ul6_expr counter drop comment \"${comment}_ul6\"" 2>/dev/null \
&& ul6_ok=1
@@ -226,11 +264,21 @@ tctl_ratelimit_add() {
# Set for the caller to report: "limited" means v4 only unless this is 1.
# shellcheck disable=SC2034
TCTL_RL_UPLOAD6_OK="$ul6_ok"
# A download rule over a set nothing feeds matches nothing, so it only
# counts when both halves loaded.
# shellcheck disable=SC2034
if [ "$dl6_ok" = "1" ] && [ "$feed6_ok" = "1" ]; then
TCTL_RL_DOWNLOAD6_OK=1
else
TCTL_RL_DOWNLOAD6_OK=0
fi
else
# fw3/iptables stays IPv4-only. A half-built ip6tables path would be
# worse than a stated gap; see docs/COMPATIBILITY.md.
# shellcheck disable=SC2034
TCTL_RL_UPLOAD6_OK=0
# shellcheck disable=SC2034
TCTL_RL_DOWNLOAD6_OK=0
iptables -t mangle -A FORWARD -d "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \
--hashlimit-mode dstip --hashlimit-name "rl_${comment}" \
@@ -247,9 +295,11 @@ tctl_ratelimit_remove() {
local chain h
if [ "$TCTL_FW" = "nft" ]; then
# Scan the whole table once: rules for this IP live in dl (daddr), in
# one ul_<dev> chain per ingress device (saddr), and — for a host with
# a known MAC — a second, IPv6-scoped rule in each of those (_ul6).
# Scan the whole table once: rules for this IP live in the dl chains
# (daddr, plus _dl6 over the v6 address set), in one ul_<dev> chain per
# ingress device (saddr), and — for a host with a known MAC — the
# IPv6-scoped _ul6 rule and the _v6 rule feeding the set in each of
# those.
#
# Each comment is matched in FULL, closing quote included. The suffixes
# are prefixes of one another ("_ul" of "_ul6"), and every comment ends
@@ -258,12 +308,15 @@ tctl_ratelimit_remove() {
nft -a list table netdev tm_ratelimit 2>/dev/null | awk -v cmt="$comment" '
/^[ \t]*chain [a-zA-Z0-9_]+ \{/ { chain = $2; next }
index($0, "\"" cmt "\"") || index($0, "\"" cmt "_ul\"") ||
index($0, "\"" cmt "_ul6\"") {
index($0, "\"" cmt "_ul6\"") || index($0, "\"" cmt "_dl6\"") ||
index($0, "\"" cmt "_v6\"") {
for (i = 1; i < NF; i++)
if ($i == "handle") { print chain, $(i+1); break }
}' | while read -r chain h; do
[ -n "$chain" ] && [ -n "$h" ] && nft delete rule netdev tm_ratelimit "$chain" handle "$h" 2>/dev/null
done
# Only once nothing references it: nft refuses to delete a set in use.
nft delete set netdev tm_ratelimit "tctl_v6_$(tctl_target_slug "$ip")" 2>/dev/null
else
while iptables -t mangle -D FORWARD -d "$ip" -m comment --comment "$comment" 2>/dev/null; do :; done
while iptables -t mangle -D FORWARD -s "$ip" -m comment --comment "${comment}_ul" 2>/dev/null; do :; done
@@ -931,6 +984,33 @@ TCTL_SEEN_MAX=1000
# TCTL_LEASES_FILE is set once at the top of this file (overridable for tests).
TCTL_SHAPES_FILE="/etc/trafficctl/shapes.json"
# "<ip> <classid>" for every shape in shapes.json — the only way from an
# address to its HTB class, because minors are ALLOCATED (see
# trafficctl-shape.sh), not derived from the address. Readers that derived
# them anyway showed one device's rate on another.
#
# Same record pattern as shapes_entries in trafficctl-shape.sh, optional
# rate_kbit_up included: a pattern that misses a record form makes that shape
# vanish from every reader. A record from before allocation carries no
# classid, and its class is still the one the old derivation gave it
# (legacy_classid there), so that is what it maps to.
tctl_shape_classmap() {
grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?(,"rate_kbit_up":[0-9]+)?\}' \
"$TCTL_SHAPES_FILE" 2>/dev/null | awk '
{
if (!match($0, /"ip":"[^"]+"/)) next
ip = substr($0, RSTART + 6, RLENGTH - 7)
if (match($0, /"classid":"1:[0-9a-f]+"/)) {
print ip, substr($0, RSTART + 11, RLENGTH - 12)
next
}
if (split(ip, o, ".") != 4) next
dec = o[3] * 256 + o[4]
if (dec < 2 || dec > 65533) next
printf "%s 1:%x\n", ip, dec
}'
}
tctl_seen_normalize() {
printf '%s' "$1" | tr 'A-Z' 'a-z'
}
@@ -70,17 +70,24 @@ else
TCTL_RL_DOWNLOAD_FAILED=0
TCTL_RL_UPLOAD_FAILED=0
TCTL_RL_UPLOAD6_OK=0
TCTL_RL_DOWNLOAD6_OK=0
tctl_ratelimit_add "$IP" "$RATE" "$COMMENT" "$MODE" "$RATE_UP"
# IPv6 coverage is partial by construction and the operator has to know
# which part: upload is policed on the MAC, download is not policed at all
# over v6 (see tctl_ratelimit_add). Claiming "both directions" without
# this note is how issue #67 stayed invisible.
if [ "$TCTL_RL_UPLOAD6_OK" = "1" ]; then
# IPv6 coverage depends on whether the target has a MAC to key on, and
# each half can fail to load on its own. The operator has to know which
# part is live: claiming "both directions" without this note is how issue
# #67 stayed invisible.
if [ "$TCTL_RL_UPLOAD6_OK" = "1" ] && [ "$TCTL_RL_DOWNLOAD6_OK" = "1" ]; then
V6NOTE=" [IPv6: both directions]"
elif [ "$TCTL_RL_UPLOAD6_OK" = "1" ]; then
V6NOTE=" [IPv6: upload only]"
elif [ "$TCTL_RL_DOWNLOAD6_OK" = "1" ]; then
V6NOTE=" [IPv6: download only]"
else
V6NOTE=" [IPv4 only]"
fi
V6UP=$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false)
V6DN=$([ "$TCTL_RL_DOWNLOAD6_OK" = "1" ] && echo true || echo false)
# A half-applied limit is a silent trap: report exactly which direction
# is live rather than claiming success for both.
@@ -108,10 +115,12 @@ else
NSS="$(tctl_nss_status)"
PERSIST=$(tctl_persist_enabled && echo true || echo false)
if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE_UP} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE$NSSNOTE\"}"
echo "{\"ok\":true,\"ipv6_upload\":$V6UP,\"ipv6_download\":$V6DN,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE_UP} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE$NSSNOTE\"}"
elif [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then
echo "{\"ok\":true,\"ipv6_upload\":false,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device [IPv4 only]$NSSNOTE\"}"
# No ingress device means nothing feeds the v6 download set either, so
# tctl_ratelimit_add has already reported that half as not live.
echo "{\"ok\":true,\"ipv6_upload\":false,\"ipv6_download\":false,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device [IPv4 only]$NSSNOTE\"}"
else
echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit $(rate_text) for $IP (both directions, $MODE)$V6NOTE$NSSNOTE\"}"
echo "{\"ok\":true,\"ipv6_upload\":$V6UP,\"ipv6_download\":$V6DN,\"nss\":\"$NSS\",\"persist\":$PERSIST,\"msg\":\"rate limit $(rate_text) for $IP (both directions, $MODE)$V6NOTE$NSSNOTE\"}"
fi
fi
@@ -21,9 +21,14 @@ fi
# Map classid → real IP from persisted shapes. Minors are allocated, not derived
# from the address, so this map is the only way back to an IP; classes missing
# from it belong to another tc user and are skipped rather than mislabelled.
CLASSMAP=$(grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+,"classid":"1:[0-9a-f]+"\}' \
/etc/trafficctl/shapes.json 2>/dev/null | \
sed -n 's/{"ip":"\([^"]*\)","rate_kbit":[0-9]*,"classid":"\(1:[0-9a-f]*\)"}/\2 \1/p')
# The pattern used to require the record to end at the classid, so every shape
# with its own upload ceiling ("rate_kbit_up") was dropped from this output,
# and with it from tctl_has_limit.
#
# Flattened to ONE "classid ip classid ip …" line: a multi-line value passed
# through `awk -v` is rejected by some awks ("newline in string"), which left
# this output empty. Neither field can contain a space.
CLASSMAP=$(tctl_shape_classmap | awk '{ printf "%s %s ", $2, $1 }')
# Collect class stats into a temp file so we can merge with qdisc stats
CLASS_DATA=$(tc -s class show dev "$LAN_DEV" 2>/dev/null)
@@ -32,11 +37,9 @@ QDISC_DATA=$(tc -s qdisc show dev "$LAN_DEV" 2>/dev/null)
# Parse class stats: emit lines "classid ip rate bytes pkts backlog drops overlimits requeues lended borrowed"
CLASS_PARSED=$(echo "$CLASS_DATA" | awk -v classmap="$CLASSMAP" '
BEGIN {
n = split(classmap, cml, "\n")
for (ci = 1; ci <= n; ci++) {
split(cml[ci], cmf, " ")
if (cmf[1] != "" && cmf[2] != "") ipmap[cmf[1]] = cmf[2]
}
n = split(classmap, cmf, " ")
for (ci = 1; ci + 1 <= n; ci += 2)
if (cmf[ci] != "" && cmf[ci + 1] != "") ipmap[cmf[ci]] = cmf[ci + 1]
}
/class fq_codel/ { skip = 1; next }
@@ -242,6 +242,9 @@ if command -v tc >/dev/null 2>&1; then
}')
fi
# address → classid, from shapes.json (see tctl_shape_classmap).
SHAPE_MAP=$(tctl_shape_classmap)
# Wifi ACL state, split by policy, because "listed" means opposite things in the
# two modes: on a deny (blacklist) radio a listed MAC is blocked, on an allow
# (whitelist) radio a listed MAC is the only kind permitted. Collected once here
@@ -355,14 +358,14 @@ lookup_rate_limit() {
echo "${r:-0}"
}
# The classid is looked up, never derived from the address: minors are
# allocated, so deriving put the first shaped device's rate on 192.168.x.2 and
# showed 0 for the device actually shaped — and the Telegram bot built its
# "Shape — Remove" button from this field.
lookup_shape_kbit() {
local ip="$1" o3 o4 dec hex classid r
o3=$(echo "$ip" | cut -d. -f3)
o4=$(echo "$ip" | cut -d. -f4)
dec=$((o3 * 256 + o4))
hex=$(printf "%x" "$dec")
case "$hex" in 1|fffe) echo 0; return ;; esac
classid="1:$hex"
local ip="$1" classid r
classid=$(echo "$SHAPE_MAP" | awk -v ip="$ip" '$1==ip{print $2; exit}')
[ -n "$classid" ] || { echo 0; return; }
r=$(echo "$TC_MAP" | awk -v c="$classid" '$1==c{print $2; exit}')
echo "${r:-0}"
}
+1 -1
View File
@@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vantage
PKG_VERSION:=1.0.14
PKG_RELEASE:=21
PKG_RELEASE:=22
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard
+3 -2
View File
@@ -659,7 +659,8 @@ msgstr ""
msgid "DNS %s"
msgstr ""
#: luci-app-vantage/root/usr/share/luci/menu.d/luci-app-vantage.json:3
#: luci-app-vantage/root/usr/share/luci/menu.d/zz-luci-app-vantage.json:3
#: luci-app-vantage/root/usr/share/luci/menu.d/zz-luci-app-vantage.json:14
msgid "Dashboard"
msgstr ""
@@ -1483,7 +1484,7 @@ msgstr ""
msgid "Overlaid, log scale"
msgstr ""
#: luci-app-vantage/root/usr/share/luci/menu.d/luci-app-vantage.json:14
#: luci-app-vantage/root/usr/share/luci/menu.d/zz-luci-app-vantage.json:26
msgid "Overview"
msgstr ""
+3 -2
View File
@@ -671,7 +671,8 @@ msgstr "DNS"
msgid "DNS %s"
msgstr "DNS %s"
#: luci-app-vantage/root/usr/share/luci/menu.d/luci-app-vantage.json:3
#: luci-app-vantage/root/usr/share/luci/menu.d/zz-luci-app-vantage.json:3
#: luci-app-vantage/root/usr/share/luci/menu.d/zz-luci-app-vantage.json:14
msgid "Dashboard"
msgstr "仪表盘"
@@ -1503,7 +1504,7 @@ msgstr "叠加"
msgid "Overlaid, log scale"
msgstr "叠加,对数刻度"
#: luci-app-vantage/root/usr/share/luci/menu.d/luci-app-vantage.json:14
#: luci-app-vantage/root/usr/share/luci/menu.d/zz-luci-app-vantage.json:26
msgid "Overview"
msgstr "概览"
@@ -1,5 +1,5 @@
{
"admin/status/dashboard": {
"admin/dashboard": {
"title": "Dashboard",
"order": 1,
"action": {
@@ -10,6 +10,18 @@
"acl": [ "luci-app-vantage" ]
}
},
"admin/status/dashboard": {
"title": "Dashboard",
"order": 2,
"action": {
"type": "view",
"path": "dashboard/index"
},
"depends": {
"acl": [ "luci-mod-dashboard-index" ],
"fs": { "/www/luci-static/resources/view/dashboard/index.js": "file" }
}
},
"admin/network/firewall/overview": {
"title": "Overview",
"order": 5,
+2 -2
View File
@@ -7,9 +7,9 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Status Pages
LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-app-vantage +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
PKG_RELEASE:=41
PKG_RELEASE:=42
PKG_BUILD_DEPENDS:=iwinfo
PKG_LICENSE:=Apache-2.0
@@ -0,0 +1,71 @@
# Navigation loading — 2026-11-03
The footer starts the menu and router modules in parallel. The router still
waits for the menu instance and `ui.menu.load()` before registering navigation
handlers. LuCI caches the pending promise for both its module loader and menu
request, so this removes the menu → router download dependency without adding
an `/admin/menu` request.
The header retains LuCI's standard translation script before `cbi.js`.
The optional catalog-directory probe was removed to keep template logic small
and avoid maintaining a separate decision about whether translations exist.
The pre-paint menu snapshot is scoped to language, user, session and theme
asset version. It is not reused after `ui.menu.flushCache()` invalidates the
LuCI session's menu. Logging out or returning to login clears the snapshot.
## Shared runtime integration
The theme pins `@eamonxg/luci-theme-devkit` to the published npm version
`0.3.2`. The lockfile records its registry integrity; no local dependency
patch or sibling checkout is required.
From the theme repository's `.dev/` directory, use the pnpm version pinned
by `package.json`:
```sh
pnpm install --frozen-lockfile
pnpm build
pnpm test
```
Shared runtime changes belong in the devkit repository. Publish the updated
npm package first, then update the theme dependency and lockfile before
rebuilding production assets.
The bounded dependency prewarmer brings the generated router from 15,771 B
to 17,806 B raw, or from 5,840 B to 6,618 B with Node gzip defaults. The router budget is now
18 KiB. Independent CSS, menu, sidebar and font budgets are unchanged.
The increase is 2,035 B raw and 778 B with Node gzip defaults.
The prewarmer fetches source without evaluating it, limits parallel requests,
honors reduced-data connections and retries failed fetches. Pointer hover
immediately fetches only the root view source; a 65 ms hover dwell then permits
dependency expansion. Keyboard `:focus-visible` may start deep warming directly.
Template shell prefetch always waits for that dwell. Pointer-down cancels the
pending timer without starting another fetch, and mouse focus starts no warmup.
A warmup generation counter lets the next intent retry after failures or a
navigation clears queued requests.
Template routes
with query parameters or wildcard arguments retain full document navigation;
template shells are cached by both template name and pathname. See the shared
devkit's maintained router documentation for the exact behavior.
## Validation
- Tests execute the footer with a never-resolving menu promise to verify the
router download starts immediately, and remains disabled without the
Navigation API.
- Cache tests cover identity/version mismatches and LuCI menu invalidation.
- A normal frozen install and production build use the published npm package. The generated router is 17,806 B, SHA-256 `c18a6f9b3a2f9b551ac7d7ee55560fdaec16901bfca9da3c9ff0dfc42a35973b`.
- Device timing and template checks are recorded in the devkit performance
report for this coordinated change; local unit timings are not device load
timings.
Aurora's combined admin asset budget remains 264,000 B. The sidebar/mega-menu
HTML stamp now includes the authentication session, so a fresh login by the
same user does not reuse the previous session's rendered menu.
The full Aurora suite passed 157 tests, including browser compatibility
fixtures, after a normal frozen install and production build with the
published npm package. The output matches the coordinated runtime.
The combined admin assets are 263,992 B (budget 264,000 B).
+1 -1
View File
@@ -13,7 +13,7 @@
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/luci-theme-devkit": "^0.3.1",
"@eamonxg/luci-theme-devkit": "0.3.2",
"@eamonxg/luci-theme-tokens": "^2.0.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.0",
+5 -5
View File
@@ -167,8 +167,8 @@ importers:
.:
devDependencies:
'@eamonxg/luci-theme-devkit':
specifier: ^0.3.1
version: 0.3.1(terser@5.46.0)(vite@7.3.1(@types/node@24.10.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0))
specifier: 0.3.2
version: 0.3.2(terser@5.46.0)(vite@7.3.1(@types/node@24.10.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0))
'@eamonxg/luci-theme-tokens':
specifier: ^2.0.0
version: 2.0.0
@@ -205,8 +205,8 @@ importers:
packages:
'@eamonxg/luci-theme-devkit@0.3.1':
resolution: {integrity: sha512-dgmdTPYnAvmRJ0uCzcx4+7X3msPct5uv1YVC5DzPBrox3ugzSHV65zW+rPEtlENW/tdz3//+cvT47fw7p8lbvA==}
'@eamonxg/luci-theme-devkit@0.3.2':
resolution: {integrity: sha512-Asyiszn6cYzkvNcsYeRKk9oBWXXfLiykrcau6dhbliK9zjr+DsQIRsz6faL3vICOOv822jH5KgXsB177TYTFwQ==}
engines: {node: '>=20.19'}
hasBin: true
peerDependencies:
@@ -937,7 +937,7 @@ packages:
snapshots:
'@eamonxg/luci-theme-devkit@0.3.1(terser@5.46.0)(vite@7.3.1(@types/node@24.10.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0))':
'@eamonxg/luci-theme-devkit@0.3.2(terser@5.46.0)(vite@7.3.1(@types/node@24.10.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0))':
dependencies:
terser: 5.46.0
vite: 7.3.1(@types/node@24.10.9)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)
@@ -1,2 +1,4 @@
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- '@eamonxg/luci-theme-devkit@0.3.2'
@@ -34,7 +34,9 @@ test("production assets stay within raw-transfer budgets", () => {
// 16K: the router now ships from @eamonxg/luci-theme-devkit — theme hooks
// as document events, the title format read off the document, and the
// inner-scroller restoration shared with shadcn (+0.8 KB raw, +0.3 KB gz).
assert.ok(router <= 16_000, "router-aurora.js exceeds 16 KB");
// 18 KiB: bounded dependency prewarm adds 2,035 B raw / 778 B gzip,
// with a two-request limit, retry/timeout handling and data-saver gates.
assert.ok(router <= 18_432, "router-aurora.js exceeds 18 KiB");
assert.ok(logo <= 16_000, "logo.svg exceeds 16 KB");
assert.ok(
main + menu + router + font + logo <= 264_000,
@@ -135,6 +135,10 @@ const run = ({
test("header.ut carries the replay and the login-page clear", () => {
assert.ok(replaySource, "replay script not found");
assert.ok(loginSource, "login-page script not found");
assert.match(
header,
/\[nav_type, dispatcher\.lang, ctx\?\.authuser \?\? '', ctx\?\.authsession \?\? ''\]/,
);
});
test("a sidebar replay paints inert and re-marks the current page", () => {
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const footer = readFileSync(
new URL("../../ucode/template/themes/aurora/footer.ut", import.meta.url),
"utf8",
);
const boot = [...footer.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((match) => match[1])
.find((script) => script.includes("L.require('menu-aurora')"));
for (const navigation of [undefined, {}]) {
test(`theme modules start without waiting for menu resolution (Navigation API: ${!!navigation})`, () => {
const requested = [];
const L = {
require(name) {
requested.push(name);
return new Promise(() => {});
},
};
new Function("L", "window", boot)(L, { navigation });
assert.ok(requested.includes("menu-aurora"));
assert.equal(requested.includes("router-aurora"), !!navigation);
});
}
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.4.1
PKG_RELEASE:=85
PKG_VERSION:=1.4.2
PKG_RELEASE:=86
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
+2 -5
View File
@@ -11,7 +11,6 @@
<a href="https://www.mozilla.org/firefox/"><img alt="Firefox" src="https://img.shields.io/badge/Firefox-%E2%89%A5128-FF7139?logo=firefoxbrowser&logoColor=white"></a>
<a href="https://github.com/eamonxg/luci-theme-aurora/releases/latest"><img alt="GitHub release" src="https://img.shields.io/github/v/release/eamonxg/luci-theme-aurora"></a>
<a href="https://github.com/eamonxg/luci-theme-aurora/releases"><img alt="Downloads" src="https://img.shields.io/github/downloads/eamonxg/luci-theme-aurora/total"></a>
<a href="https://discord.gg/EBncRrzfTw"><img alt="Discord" src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white"></a>
<a href="#contributing"><img alt="Contributors" src="https://img.shields.io/github/contributors/eamonxg/luci-theme-aurora?color=orange"></a>
</div>
@@ -80,11 +79,11 @@ OpenWrt 25.12+ and snapshots use `apk`; other versions use `opkg`:
cd /tmp
# opkg
uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.4.1-r20261031_all.ipk
uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.4.0-r20260920_all.ipk
opkg install luci-theme-aurora.ipk
# apk
uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.4.1-r20261031.apk
uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.4.0-r20260920.apk
apk add --allow-untrusted luci-theme-aurora.apk
```
@@ -133,8 +132,6 @@ make package/luci-theme-aurora/compile -j$(nproc) V=s
Aurora uses **Vite** and a modern front-end toolchain, and is experimenting with end-to-end AI integration across the full development workflow. See [Development Documentation](.dev/docs/DEVELOPMENT.md) to get started. Suggestions and PRs are always welcome.
[discord.gg/EBncRrzfTw](https://discord.gg/EBncRrzfTw)
Thanks goes to these wonderful people:
<!-- contributors:start -->
+2 -5
View File
@@ -11,7 +11,6 @@
<a href="https://www.mozilla.org/firefox/"><img alt="Firefox" src="https://img.shields.io/badge/Firefox-%E2%89%A5128-FF7139?logo=firefoxbrowser&logoColor=white"></a>
<a href="https://github.com/eamonxg/luci-theme-aurora/releases/latest"><img alt="GitHub release" src="https://img.shields.io/github/v/release/eamonxg/luci-theme-aurora"></a>
<a href="https://github.com/eamonxg/luci-theme-aurora/releases"><img alt="Downloads" src="https://img.shields.io/github/downloads/eamonxg/luci-theme-aurora/total"></a>
<a href="https://discord.gg/EBncRrzfTw"><img alt="Discord" src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white"></a>
<a href="#加入贡献"><img alt="Contributors" src="https://img.shields.io/github/contributors/eamonxg/luci-theme-aurora?color=orange"></a>
</div>
@@ -80,11 +79,11 @@ OpenWrt 25.12+ 和 Snapshot 版本使用 `apk`;其他版本使用 `opkg`:
cd /tmp
# opkg
uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.4.1-r20261031_all.ipk
uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.4.0-r20260920_all.ipk
opkg install luci-theme-aurora.ipk
# apk
uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.4.1-r20261031.apk
uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.4.0-r20260920.apk
apk add --allow-untrusted luci-theme-aurora.apk
```
@@ -133,8 +132,6 @@ make package/luci-theme-aurora/compile -j$(nproc) V=s
Aurora 使用 **Vite** 与现代前端工具链构建,并尝试将 AI 融入开发全链路。详见[开发文档](.dev/docs/DEVELOPMENT_zh.md)。欢迎提交建议或 PR。
[discord.gg/EBncRrzfTw](https://discord.gg/EBncRrzfTw)
感谢这些出色的贡献者:
<!-- contributors:start -->
@@ -1 +1 @@
"use strict";"require baseclass";"require ui";"require poll";"require rpc";const RT=window.L,RENDER_TIMEOUT=15e3,PATCH_ATTR="data-luci-patch",NODE_CSS_ATTR="data-luci-node-css",SHELL_ATTR="data-luci-shell",PROGRESS_DELAY=150,INSTANTIATE=/instantiateView\(\s*['"]([^'"]+)['"]/,TITLE_SLOT="\0",supported=()=>"object"==typeof navigation&&null!==navigation&&"function"==typeof navigation.addEventListener&&"function"==typeof NavigateEvent&&"intercept"in NavigateEvent.prototype,nodeWeight=e=>Math.min(e.order??9999,9999)+(e.auth?.login?1e4:0);function firstChild(e){let t=null;for(const[n,s]of Object.entries(e.children??{}))if(s.satisfied&&s.title&&"object"==typeof s.action)if("firstchild"===s.action?.type){if(t&&nodeWeight(t.node)<=nodeWeight(s))continue;firstChild(s)&&(t={name:n,node:s})}else s.firstchild_ineligible||(!t||nodeWeight(t.node)>nodeWeight(s))&&(t={name:n,node:s});return t}function resolve(e,t){let n=e,s=[],i=[],r=0;for(let o=0;o<32;o++){for(;r<t.length;){const e=n.children?.[t[r]];if(e&&!1===e.satisfied)return null;if(!e){if(!n.wildcard)return null;i=t.slice(r),r=t.length;break}n=e,s.push(t[r++])}const o=n.action?.type;if("alias"===o)t=[...String(n.action.path??"").split("/").filter(Boolean),...i],n=e,s=[],i=[],r=0;else{if("firstchild"!==o)return{node:n,path:s,args:i,request:t};{const e=firstChild(n);if(!e)return null;n=e.node,s.push(e.name)}}}return null}function readonlyAlong(e,t){const n=[];let s=e;for(const e of t)s=s?.children?.[e],s?.depends?.acl?.length&&n.push(s);return n.length>0&&n.every(e=>!0===e.readonly)}const actionOf=(e,t)=>t.length&&"object"==typeof e?.wildcardaction?e.wildcardaction:e?.action;function viewClass(e){return"view"===e?.type&&e.path?`view.${e.path.replace(/\//g,".")}`:null}const PRAGMA_HEAD=/^(?:\s*["'](?:use strict|require\s[^"']+)["'];)+/,PRAGMA=/["']require\s+([^\s"']+)/g;function pragmaDeps(e){return[...(e.slice(0,4096).match(PRAGMA_HEAD)?.[0]??"").matchAll(PRAGMA)].map(e=>e[1])}const moduleUrl=e=>`${RT.env.base_url}/${e.replace(/\./g,"/")}.js${RT.env.resource_version?`?v=${RT.env.resource_version}`:""}`,TITLE_SEPARATOR=/\s*[|\-–—:·]\s*\0|\0\s*[|\-–—:·]\s*/,patchPrefixes=e=>{const t=[];let n=null;for(const s of e)t.push(n=null==n?s:`${n}-${s}`);return t},patchAssetUrl=e=>{const t=document.body.dataset.assetVersion;return t?`${e}?v=${t}`:e},installedPatches=()=>(document.body.dataset.patches??"").split(/\s+/).filter(Boolean),patchRegistry=()=>window.luciPatches??={};let livePatches=new Set,pendingPatches=[];function applyPatches(e){const t=new Set(patchPrefixes(e)),n=RT.env.media,s=[];for(const e of installedPatches()){const i=e.replace(/\.(css|js)$/,""),r=t.has(i);if(e.endsWith(".css")){let e=document.querySelector(`link[${PATCH_ATTR}="${i}"]`);!e&&r?(e=E("link",{rel:"stylesheet",href:patchAssetUrl(`${n}/patches/${i}.css`),[PATCH_ATTR]:i}),document.head.appendChild(e)):e&&(e.disabled=!r)}else r&&s.push(i)}return s}function mountPatches(e){const t=patchRegistry();livePatches=new Set(e);for(const n of e)if(document.querySelector(`script[${PATCH_ATTR}="${n}"]`))t[n]?.mount?.();else{const e=E("script",{src:patchAssetUrl(`${RT.env.media}/patches/${n}.js`),[PATCH_ATTR]:n});e.addEventListener("load",()=>{livePatches.has(n)||patchRegistry()[n]?.unmount?.()}),document.head.appendChild(e)}}function unmountPatches(){livePatches=new Set;const e=patchRegistry();for(const t in e)e[t].unmount?.()}return document.addEventListener("luci-navigate",e=>{unmountPatches(),pendingPatches=applyPatches(e.detail.request)}),document.addEventListener("luci-navigated",()=>{mountPatches(pendingPatches),pendingPatches=[]}),baseclass.extend({__init__(){if(!supported()||!RT?.env?.scriptname)return;const e=this.contract();if(e.length)return void console.warn(`luci-router: luci-base surface missing, staying MPA: ${e.join(", ")}`);this.gen=0,this.seen=new Set,this.warmed=new Set,this.inflight=Promise.resolve(),this.intervals=new Set,this.scrolls=new Map,this.knownSheets=new WeakSet(this.sheets().filter(e=>e.hasAttribute(SHELL_ATTR)||e.hasAttribute(PATCH_ATTR)||e.hasAttribute(NODE_CSS_ATTR))),this.hookIntervals(),this.hookListeners(),this.hookSession(),this.hookVisibility();const t=RT.env.media.split("/").filter(Boolean).pop();Promise.all([ui.menu.load(),RT.require(`menu-${t}`).catch(()=>null)]).then(([e,t])=>{this.tree=e,this.menu=t;const n=this.initialRoute();n&&(document.querySelector('script[type="speculationrules"]')?.remove(),this.status=document.body.appendChild(E("div",{id:"luci-nav-status",role:"status","aria-live":"polite","aria-atomic":"true"})),navigation.addEventListener("navigate",e=>this.onNavigate(e)),document.addEventListener("pointerover",e=>this.onIntent(e)),document.addEventListener("pointerdown",e=>this.onIntent(e)),document.addEventListener("focusin",e=>this.onIntent(e)),this.watchInitialRender(n))})},contract(){const e={L:RT,rpc:rpc,poll:poll,ui:ui,window:window};return["L.view","L.require","L.dom.content","L.env.base_url","L.env.resource","L.env.media","L.Request.addIntercLine truncated
"use strict";"require baseclass";"require ui";"require poll";"require rpc";const RT=window.L,RENDER_TIMEOUT=15e3,PATCH_ATTR="data-luci-patch",NODE_CSS_ATTR="data-luci-node-css",SHELL_ATTR="data-luci-shell",PROGRESS_DELAY=150,INTENT_DELAY=65,WARM_CONCURRENCY=2,WARM_TIMEOUT=5e3,INSTANTIATE=/instantiateView\(\s*['"]([^'"]+)['"]/,TITLE_SLOT="\0",supported=()=>"object"==typeof navigation&&null!==navigation&&"function"==typeof navigation.addEventListener&&"function"==typeof NavigateEvent&&"intercept"in NavigateEvent.prototype,nodeWeight=e=>Math.min(e.order??9999,9999)+(e.auth?.login?1e4:0);function firstChild(e){let t=null;for(const[s,n]of Object.entries(e.children??{}))if(n.satisfied&&n.title&&"object"==typeof n.action)if("firstchild"===n.action?.type){if(t&&nodeWeight(t.node)<=nodeWeight(n))continue;firstChild(n)&&(t={name:s,node:n})}else n.firstchild_ineligible||(!t||nodeWeight(t.node)>nodeWeight(n))&&(t={name:s,node:n});return t}function resolve(e,t){let s=e,n=[],i=[],r=0;for(let o=0;o<32;o++){for(;r<t.length;){const e=s.children?.[t[r]];if(e&&!1===e.satisfied)return null;if(!e){if(!s.wildcard)return null;i=t.slice(r),r=t.length;break}s=e,n.push(t[r++])}const o=s.action?.type;if("alias"===o)t=[...String(s.action.path??"").split("/").filter(Boolean),...i],s=e,n=[],i=[],r=0;else{if("firstchild"!==o)return{node:s,path:n,args:i,request:t};{const e=firstChild(s);if(!e)return null;s=e.node,n.push(e.name)}}}return null}function readonlyAlong(e,t){const s=[];let n=e;for(const e of t)n=n?.children?.[e],n?.depends?.acl?.length&&s.push(n);return s.length>0&&s.every(e=>!0===e.readonly)}const actionOf=(e,t)=>t.length&&"object"==typeof e?.wildcardaction?e.wildcardaction:e?.action;function viewClass(e){return"view"===e?.type&&e.path?`view.${e.path.replace(/\//g,".")}`:null}const BUILTIN_MODULES=new Set(["baseclass","dom","poll","request","session","view"]),MODULE_NAME=/^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/,PRAGMA=/(?:\s|;|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*(["'])(use[ \t]+strict|require[ \t]+[^"'\r\n]+)\1/y;function pragmaDeps(e){const t=e.slice(0,4096),s=[];PRAGMA.lastIndex=0;for(let e;e=PRAGMA.exec(t);){const t=/^require[ \t]+(\S+)(?:[ \t]+as[ \t]+[a-zA-Z_]\S*)?$/.exec(e[2]);if(t)MODULE_NAME.test(t[1])&&s.push(t[1]);else if(!/^use[ \t]+strict$/.test(e[2]))break}return s}const moduleUrl=e=>`${RT.env.base_url}/${e.replace(/\./g,"/")}.js${RT.env.resource_version?`?v=${RT.env.resource_version}`:""}`,TITLE_SEPARATOR=/\s*[|\-–—:·]\s*\0|\0\s*[|\-–—:·]\s*/,patchPrefixes=e=>{const t=[];let s=null;for(const n of e)t.push(s=null==s?n:`${s}-${n}`);return t},patchAssetUrl=e=>{const t=document.body.dataset.assetVersion;return t?`${e}?v=${t}`:e},installedPatches=()=>(document.body.dataset.patches??"").split(/\s+/).filter(Boolean),patchRegistry=()=>window.luciPatches??={};let livePatches=new Set,pendingPatches=[];function applyPatches(e){const t=new Set(patchPrefixes(e)),s=RT.env.media,n=[];for(const e of installedPatches()){const i=e.replace(/\.(css|js)$/,""),r=t.has(i);if(e.endsWith(".css")){let e=document.querySelector(`link[${PATCH_ATTR}="${i}"]`);!e&&r?(e=E("link",{rel:"stylesheet",href:patchAssetUrl(`${s}/patches/${i}.css`),[PATCH_ATTR]:i}),document.head.appendChild(e)):e&&(e.disabled=!r)}else r&&n.push(i)}return n}function mountPatches(e){const t=patchRegistry();livePatches=new Set(e);for(const s of e)if(document.querySelector(`script[${PATCH_ATTR}="${s}"]`))t[s]?.mount?.();else{const e=E("script",{src:patchAssetUrl(`${RT.env.media}/patches/${s}.js`),[PATCH_ATTR]:s});e.addEventListener("load",()=>{livePatches.has(s)||patchRegistry()[s]?.unmount?.()}),document.head.appendChild(e)}}function unmountPatches(){livePatches=new Set;const e=patchRegistry();for(const t in e)e[t].unmount?.()}return document.addEventListener("luci-navigate",e=>{unmountPatches(),pendingPatches=applyPatches(e.detail.request)}),document.addEventListener("luci-navigated",()=>{mountPatches(pendingPatches),pendingPatches=[]}),baseclass.extend({__init__(){if(!supported()||!RT?.env?.scriptname)return;const e=this.contract();if(e.length)return void console.warn(`luci-router: luci-base surface missing, staying MPA: ${e.join(", ")}`);this.gen=0,this.seen=new Set,this.warmed=new Map,this.warmQueue=[],this.warmActive=0,this.warmEpoch=0,this.inflight=Promise.resolve(),this.intervals=new Set,this.scrolls=new Map,this.knownSheets=new WeakSet(this.sheets().filter(e=>e.hasAttribute(SHELL_ATTR)||e.hasAttribute(PATCH_ATTR)||e.hasAttribute(NODE_CSS_ATTR))),this.hookIntervals(),this.hookListeners(),this.hookSession(),this.hookVisibility();const t=RT.env.media.split("/").filter(Boolean).pop();Promise.all([ui.menu.load(),RT.require(`menu-${t}`).catch(()=>null)]).then(([e,t])=>{this.tree=e,this.menu=t;const s=this.initialRoute();s&&(document.querySelector('script[type="speculationrules"]')?.remove(),this.status=document.body.appendChild(E("div",{id:"luci-nav-status",role:"status","aria-live":"polite","aria-atomic":"true"})),navigation.addEventListener("navigate",e=>thiLine truncated
@@ -34,7 +34,10 @@
</div>
</footer>
</div>
<script>L.require('menu-aurora').then(() => { if (window.navigation) L.require('router-aurora'); })</script>
<script>
L.require('menu-aurora');
if (window.navigation) L.require('router-aurora');
</script>
{% endif %}
</body>
</html>
@@ -227,7 +227,7 @@
{% endif %}
</head>
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-nav-type="{{ nav_type }}" data-asset-version="{# PKG_VERSION #}"{% if (!blank_page): %} data-nav-stamp="{{ entityencode(join(':', [nav_type, dispatcher.lang, ctx?.authuser ?? '']), true) }}:{# PKG_VERSION #}"{% endif %}{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}{% if (blank_page ? tokens.struct_login_bg : tokens.struct_main_bg): %} data-bg{% endif %}>
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-nav-type="{{ nav_type }}" data-asset-version="{# PKG_VERSION #}"{% if (!blank_page): %} data-nav-stamp="{{ entityencode(join(':', [nav_type, dispatcher.lang, ctx?.authuser ?? '', ctx?.authsession ?? '']), true) }}:{# PKG_VERSION #}"{% endif %}{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}{% if (blank_page ? tokens.struct_login_bg : tokens.struct_main_bg): %} data-bg{% endif %}>
{% if (!blank_page): %}
{% if (nav_type == 'sidebar'): %}
{# First in body: once anything resolves body's style (the page-bg script does), the column would ease shut on load. #}
@@ -339,7 +339,7 @@
(() => {
try {
const body = document.body, cache = JSON.parse(sessionStorage.getItem('aurora.nav'));
{# The stamp (nav type, language, user, version) is rendered on body; a different user sees a different menu. #}
{# The stamp (nav type, language, user, session, version) is rendered on body; a different user sees a different menu. #}
{# luci-base's one-session store loses its menu on ui.menu.flushCache() (package changes): never replay past that. #}
const luci = Object.values(JSON.parse(sessionStorage.getItem('luci-session-store')) ?? {})[0];
if (!luci?.menu || cache?.[0] !== body.dataset.navStamp) return;
+5
View File
@@ -13,6 +13,11 @@
`htdocs/` is generated output checked into git. Rebuild it with `pnpm build`, or trigger the manual `frontend-assets-build.yml` workflow, which builds and commits `htdocs/**`.
Install from `.dev/` using the pnpm version pinned in `package.json` and
`pnpm install --frozen-lockfile`, as the asset workflow does. The shared router
comes from the published `@eamonxg/luci-theme-devkit@0.3.2` package.
See [navigation-loading.md](navigation-loading.md) for the integration details.
## Terser options that are load-bearing
`.dev/src/resource/*.js` goes through terser (compress + local-scope mangle, no bundling); each file stays a standalone LuCI `L.require()`-able module. Two options must not be dropped:
@@ -0,0 +1,75 @@
# Navigation loading — 0.6.2 (2026-11-04)
The footer starts the menu and router modules in parallel. The router still
waits for the menu instance and `ui.menu.load()` before registering navigation
handlers. LuCI caches the pending promise for both its module loader and menu
request, so this removes the menu → router download dependency without adding
an `/admin/menu` request.
The header retains LuCI's standard translation script before `cbi.js`.
The optional catalog-directory probe was removed to keep template logic small
and avoid maintaining a separate decision about whether translations exist.
The pre-paint menu snapshot is scoped to language, user, session and theme
asset version. It is not reused after `ui.menu.flushCache()` invalidates the
LuCI session's menu. Logging out or returning to login clears the snapshot.
## Shared runtime integration
The theme pins `@eamonxg/luci-theme-devkit` to the published npm version
`0.3.2`. The lockfile records its registry integrity; no local dependency
patch or sibling checkout is required.
From the theme repository's `.dev/` directory, use the pnpm version pinned
by `package.json`:
```sh
pnpm install --frozen-lockfile
pnpm build
pnpm test
```
Shared runtime changes belong in the devkit repository. Publish the updated
npm package first, then update the theme dependency and lockfile before
rebuilding production assets.
The bounded dependency prewarmer brings the generated router from 15,771 B
to 17,806 B raw, or from 5,840 B to 6,618 B with Node gzip defaults. The router budget is now
18 KiB. Independent CSS, menu, sidebar and font budgets are unchanged.
The increase is 2,035 B raw and 778 B with Node gzip defaults.
The prewarmer fetches source without evaluating it, limits parallel requests,
honors reduced-data connections and retries failed fetches. Pointer hover
immediately fetches only the root view source; a 65 ms hover dwell then permits
dependency expansion. Keyboard `:focus-visible` may start deep warming directly.
Template shell prefetch always waits for that dwell. Pointer-down cancels the
pending timer without starting another fetch, and mouse focus starts no warmup.
A warmup generation counter lets the next intent retry after failures or a
navigation clears queued requests.
Template routes
with query parameters or wildcard arguments retain full document navigation;
template shells are cached by both template name and pathname. See the shared
devkit's maintained router documentation for the exact behavior.
## Validation
- Tests execute the footer with a never-resolving menu promise to verify the
router download starts immediately, and remains disabled without the
Navigation API.
- Cache tests cover identity/version mismatches and LuCI menu invalidation.
- Ordinary production builds use the published npm package. Both theme routers are
17,806 B, SHA-256 `c18a6f9b3a2f9b551ac7d7ee55560fdaec16901bfca9da3c9ff0dfc42a35973b`.
- Device timing and template checks are recorded in the devkit performance
report for this coordinated change; local unit timings are not device load
timings.
Shadcn renders its sidebar chrome and enables delegated controls while the
menu tree is still loading. Only navigation items wait for the authoritative
menu. The replay cache schema is version 2 (`{v, stamp, html, scroll}`).
The combined admin budget increases only by the router allowance delta:
217,500 + (18,432 − 16,000) = 219,932 B. A delayed-menu test verifies chrome
and the ready event are available before the response arrives.
The shadcn suite passed all 26 tests after the frozen install and ordinary rebuild;
color/contrast build checks also pass. Combined admin assets are 219,373 B
(budget 219,932 B). The menu asset is 17,007 B, two bytes smaller than before.
+1 -1
View File
@@ -9,7 +9,7 @@
How the theme turns a menu click into an in-document view swap instead of a
full page load, where it deliberately does not, and the invariants a router
inside LuCI has to keep. Source: `.dev/src/resource/router-shadcn.js`,
loaded from `footer.ut` after `menu-shadcn.js`. **No changes to luci-base
requested from `footer.ut` in parallel with `menu-shadcn.js`; the router waits for the menu instance and shared menu tree before installing navigation handlers. **No changes to luci-base
or to any view** — the router is additive theme JS plus small template hooks
(a patch manifest, `data-shadcn-*` markers on the stylesheets header.ut
itself renders, the asset version, a focusable `#maincontent`).
+2 -2
View File
@@ -2,11 +2,11 @@
- `header.ut`: near-minimal shell — empty `#sidebar` plus a parser-blocking inline script right after it that replays the sidebar cache (below) before first paint; sidebar chrome + nav are otherwise built client-side in `menu-shadcn.js`.
- `sidebar-shadcn.js`: state machine for theme (light/dark/device), sidebar collapse/expand, accordion, and mobile drawer — exposed as `window.ShadcnSidebar` after the `shadcn-sidebar-ready` event fires.
- `menu-shadcn.js`: resolves the `admin` branch of `ui.menu.load()`, then renders a two-level sidebar; `ICON_MAP` maps a LuCI menu node's `name` to `/shadcn/icons/*.svg`; deeper levels render as `#tabmenu`. `syncRoute()` re-marks the active sidebar item (same longest-link-prefix rule as the header.ut replay), breadcrumb and tabs from `L.env` after a [router](router.md) swap without rebuilding the sidebar; `closeSurfaces()` closes palette/drawer/popover.
- `menu-shadcn.js`: builds the sidebar chrome and emits `shadcn-sidebar-ready` before waiting for the menu request, so the branding, theme toggle and drawer/collapse controls are immediately available. Its navigation listeners are delegated. It resolves the `admin` branch of `ui.menu.load()`, then renders a two-level sidebar; `ICON_MAP` maps a LuCI menu node's `name` to `/shadcn/icons/*.svg`; deeper levels render as `#tabmenu`. `syncRoute()` re-marks the active sidebar item (same longest-link-prefix rule as the header.ut replay), breadcrumb and tabs from `L.env` after a [router](router.md) swap without rebuilding the sidebar; `closeSurfaces()` closes palette/drawer/popover.
## Sidebar cache (anti-flash)
`menu-shadcn.js` snapshots `#sidebar.innerHTML` + scroll position into `sessionStorage['shadcn.sidebar.cache']` (`{v, lang, html, scroll}`) after render and on `pagehide`. The `header.ut` inline script replays it pre-paint on the next navigation, recomputes the active highlight for the current URL (longest link-path prefix — keep in sync with menu-shadcn's dispatchpath matching), restores accordion/scroll state, and sets `data-shadcn-built` / `data-shadcn-restored` on `#sidebar`.
`menu-shadcn.js` snapshots `#sidebar.innerHTML` + scroll position into `sessionStorage['shadcn.sidebar.cache']` (`{v: 2, stamp, html, scroll}`) after render and on `pagehide`. The stamp binds the language, user, session and asset version. The `header.ut` inline script only replays while LuCI's session store still contains its menu, so `ui.menu.flushCache()` also invalidates the snapshot; the login page clears it. It replays pre-paint on the next navigation, recomputes the active highlight for the current URL (longest link-path prefix — keep in sync with menu-shadcn's dispatchpath matching), restores accordion/scroll state, and sets `data-shadcn-built` / `data-shadcn-restored` on `#sidebar`.
When restored, `renderSidebarChrome` only re-syncs the hostname, and `renderSidebarNav` preserves accordion/scroll across its authoritative rebuild.
+1 -1
View File
@@ -20,7 +20,7 @@
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/luci-theme-devkit": "^0.3.1",
"@eamonxg/luci-theme-devkit": "0.3.2",
"@eamonxg/luci-theme-tokens": "^2.0.0",
"@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
+5 -5
View File
@@ -167,8 +167,8 @@ importers:
.:
devDependencies:
'@eamonxg/luci-theme-devkit':
specifier: ^0.3.1
version: 0.3.1(terser@5.47.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))
specifier: 0.3.2
version: 0.3.2(terser@5.47.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))
'@eamonxg/luci-theme-tokens':
specifier: ^2.0.0
version: 2.0.0
@@ -211,8 +211,8 @@ importers:
packages:
'@eamonxg/luci-theme-devkit@0.3.1':
resolution: {integrity: sha512-dgmdTPYnAvmRJ0uCzcx4+7X3msPct5uv1YVC5DzPBrox3ugzSHV65zW+rPEtlENW/tdz3//+cvT47fw7p8lbvA==}
'@eamonxg/luci-theme-devkit@0.3.2':
resolution: {integrity: sha512-Asyiszn6cYzkvNcsYeRKk9oBWXXfLiykrcau6dhbliK9zjr+DsQIRsz6faL3vICOOv822jH5KgXsB177TYTFwQ==}
engines: {node: '>=20.19'}
hasBin: true
peerDependencies:
@@ -858,7 +858,7 @@ packages:
snapshots:
'@eamonxg/luci-theme-devkit@0.3.1(terser@5.47.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))':
'@eamonxg/luci-theme-devkit@0.3.2(terser@5.47.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0))':
dependencies:
terser: 5.47.1
vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0)
@@ -1,2 +1,4 @@
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- "@eamonxg/luci-theme-devkit@0.3.2"
@@ -39,13 +39,15 @@ const RECENTS_KEY = "shadcn.palette.recents";
return baseclass.extend({
__init__() {
// Branding and sidebar controls need no menu RPC. Start them while the
// authoritative tree loads; the sidebar's nav handlers are delegated.
this.renderSidebarChrome();
this.initUciIndicator();
document.dispatchEvent(new Event("shadcn-sidebar-ready"));
ui.menu.load().then((tree) => {
this.renderSidebarChrome();
this.render(tree);
this.initUciIndicator();
this.cacheSidebar();
window.addEventListener("pagehide", () => this.cacheSidebar());
document.dispatchEvent(new Event("shadcn-sidebar-ready"));
});
},
@@ -63,8 +65,8 @@ return baseclass.extend({
sessionStorage.setItem(
CACHE_KEY,
JSON.stringify({
v: 1,
lang: document.documentElement.lang || "",
v: 2,
stamp: document.body.dataset.navStamp,
html: sidebar.innerHTML,
scroll: nav.scrollTop || 0,
}),
@@ -68,11 +68,14 @@ test("production assets stay within raw-transfer budgets", () => {
// recents mapping, parent-aware scoring, per-segment path words) added
// ~1.2 KB; the admin total below moved by the same amount.
assert.ok(menu <= 17_500, `menu-shadcn.js ${menu} B exceeds 17.5 KB`);
assert.ok(router <= 16_000, `router-shadcn.js ${router} B exceeds 16 KB`);
// 18 KiB: bounded dependency prewarm adds 2,035 B raw / 778 B gzip,
// with a two-request limit, retry/timeout handling and data-saver gates.
assert.ok(router <= 18_432, `router-shadcn.js ${router} B exceeds 18 KiB`);
assert.ok(sidebar <= 6_500, `sidebar-shadcn.js ${sidebar} B exceeds 6.5 KB`);
assert.ok(
main + menu + router + sidebar + font <= 217_500,
"admin assets exceed 217.5 KB",
// Only the router allowance grows: 18,432 - 16,000 = 2,432 B.
main + menu + router + sidebar + font <= 219_932,
"admin assets exceed 219,932 B",
);
assert.ok(login + font + logo <= 60_000, "login assets exceed 60 KB");
});
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const footer = readFileSync(
new URL("../../ucode/template/themes/shadcn/footer.ut", import.meta.url),
"utf8",
);
const boot = [...footer.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((match) => match[1])
.find((script) => script.includes("L.require('menu-shadcn')"));
for (const navigation of [undefined, {}]) {
test(`theme modules start without waiting for menu resolution (Navigation API: ${!!navigation})`, () => {
const requested = [];
const L = {
require(name) {
requested.push(name);
return new Promise(() => {});
},
};
new Function("L", "window", boot)(L, { navigation });
assert.ok(requested.includes("menu-shadcn"));
assert.equal(requested.includes("router-shadcn"), !!navigation);
});
}
const menuSource = readFileSync(
new URL("../src/resource/menu-shadcn.js", import.meta.url),
"utf8",
);
test("sidebar chrome and controls are ready while the menu request is pending", async () => {
const calls = [];
let resolveMenu;
const pendingMenu = new Promise((resolve) => {
resolveMenu = resolve;
});
const menu = new Function(
"baseclass",
"ui",
"document",
"window",
menuSource,
)(
{ extend: (value) => value },
{ menu: { load: () => pendingMenu } },
{ dispatchEvent: (event) => calls.push(event.type) },
{ addEventListener: (event) => calls.push(event) },
);
menu.renderSidebarChrome = () => calls.push("chrome");
menu.initUciIndicator = () => calls.push("indicator");
menu.render = (tree) => calls.push(tree);
menu.cacheSidebar = () => calls.push("cache");
menu.__init__();
assert.deepEqual(calls, ["chrome", "indicator", "shadcn-sidebar-ready"]);
const tree = { children: {} };
resolveMenu(tree);
await pendingMenu;
assert.deepEqual(calls, [
"chrome",
"indicator",
"shadcn-sidebar-ready",
tree,
"cache",
"pagehide",
]);
});
const header = readFileSync(
new URL("../../ucode/template/themes/shadcn/header.ut", import.meta.url),
"utf8",
);
const replay = [...header.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((match) => match[1])
.find((script) => script.includes("Pre-paint sidebar restore"));
function restore({
stamp = "lang:user:session:version",
cachedStamp = stamp,
menu = {},
version = 2,
} = {}) {
const sidebar = {
innerHTML: "",
setAttribute() {},
querySelectorAll: () => [],
querySelector: () => null,
};
const store = {
"shadcn.sidebar.cache": JSON.stringify({
v: version,
stamp: cachedStamp,
html: "<nav>cached</nav>",
}),
"luci-session-store": JSON.stringify({ session: { menu } }),
};
new Function("document", "sessionStorage", "location", "window", replay)(
{
addEventListener() {},
getElementById: () => sidebar,
body: { dataset: { navStamp: stamp } },
},
{
getItem: (key) => store[key] || null,
removeItem: (key) => delete store[key],
},
{ pathname: "/cgi-bin/luci/admin/status/overview" },
{},
);
return sidebar.innerHTML;
}
test("pre-paint sidebar cache survives within the same active session", () => {
assert.equal(restore(), "<nav>cached</nav>");
});
test("pre-paint sidebar cache is invalidated by identity, version and LuCI menu flush", () => {
for (const options of [
{ cachedStamp: "lang:other:session:version" },
{ cachedStamp: "lang:user:old-session:version" },
{ cachedStamp: "lang:user:session:old-version" },
{ cachedStamp: "other-lang:user:session:version" },
{ menu: null },
{ version: 1 },
])
assert.equal(restore(options), "", JSON.stringify(options));
assert.match(header, /ctx\?\.authsession/);
});
+1 -1
View File
@@ -29,7 +29,7 @@ Details — output map, load-bearing terser options, dev-server env, Vite plugin
- **CSS**: TailwindCSS v4 `@apply` + CSS Nesting everywhere; raw declarations only where `@apply` can't express the rule. Use unified Tailwind responsive variants (`max-md:`, `md:`) instead of ad-hoc raw media queries. `main.css` import order is the cascade order. Never wrap theme partials in `@layer`. Don't edit generated `src/media/_tokens.css` — edit `tokens/*.js` and run `pnpm gen:tokens`. → **[.dev/docs/css.md](.dev/docs/css.md)**
- **Page patches**: per-page third-party fixes in `src/media/patches/<page>.css` (+ optional `<page>.js`), discovered at render time by `header.ut`, not bundled into `main.css`. This is the one place that writes **plain CSS**, not `@apply`. **Never use page patches for official LuCI core pages (e.g. `admin/status`) or shared LuCI structures (`.ifacebox`, `.cbi-section`)** — identify their component under `src/media/components/` (e.g. `_card.css`) and fix them in core CSS. → **[.dev/docs/patches.md](.dev/docs/patches.md)**
- **Client-side router**: `router-shadcn.js` turns navigation between LuCI **view** pages into same-document swaps via the Navigation API; every other case (call/cbi/function pages, no API, a poisoned document, an expired session) stays a full load. **Read the doc before touching navigation, teardown, page-scoped patches, `header.ut`'s `<head>`, or `#maincontent`'s scroll** — it depends on template hooks (`data-shadcn-shell`, `body[data-asset-version]`, `body[data-patches]`, `#maincontent[tabindex=-1]`) and on `menu-shadcn.js` keeping `syncRoute()`/`closeSurfaces()` working. Budget: 15 KB built. → **[.dev/docs/router.md](.dev/docs/router.md)**
- **Client-side router**: `router-shadcn.js` turns navigation between LuCI **view** pages into same-document swaps via the Navigation API; every other case (call/cbi/function pages, no API, a poisoned document, an expired session) stays a full load. **Read the doc before touching navigation, teardown, page-scoped patches, `header.ut`'s `<head>`, or `#maincontent`'s scroll** — it depends on template hooks (`data-shadcn-shell`, `body[data-asset-version]`, `body[data-patches]`, `#maincontent[tabindex=-1]`) and on `menu-shadcn.js` keeping `syncRoute()`/`closeSurfaces()` working. Budget: 18 KiB built. → **[.dev/docs/router.md](.dev/docs/router.md)**
- **Sidebar & menu**: built client-side in `menu-shadcn.js`; `header.ut` replays a `sessionStorage` cache pre-paint to avoid a flash. Bump the cache `v` whenever the sidebar markup changes shape. → **[.dev/docs/sidebar.md](.dev/docs/sidebar.md)**
- **Mock pages**: style a third-party app's page without having the app or a device — snapshots in `.dev/mocks/`, served at `/mocks/`. Snapshots are not portable between themes. → **[.dev/docs/mock-pages.md](.dev/docs/mock-pages.md)**
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=A modern sidebar LuCI theme for OpenWrt, built with shadcn/ui design language.
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=0.6.1
PKG_RELEASE:=9
PKG_VERSION:=0.6.2
PKG_RELEASE:=10
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
+2 -2
View File
@@ -62,11 +62,11 @@ OpenWrt 25.12+ and snapshots use `apk`; older versions use `opkg`.
cd /tmp
# opkg
uclient-fetch -O luci-theme-shadcn.ipk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn_0.6.1-r20261031_all.ipk
uclient-fetch -O luci-theme-shadcn.ipk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn_0.6.0-r20260920_all.ipk
opkg install luci-theme-shadcn.ipk
# apk
uclient-fetch -O luci-theme-shadcn.apk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn-0.6.1-r20261031.apk
uclient-fetch -O luci-theme-shadcn.apk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn-0.6.0-r20260920.apk
apk add --allow-untrusted luci-theme-shadcn.apk
```
+2 -2
View File
@@ -62,11 +62,11 @@ OpenWrt 25.12+ 及 Snapshot 版本使用 `apk`;旧版本使用 `opkg`。
cd /tmp
# opkg
uclient-fetch -O luci-theme-shadcn.ipk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn_0.6.1-r20261031_all.ipk
uclient-fetch -O luci-theme-shadcn.ipk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn_0.6.0-r20260920_all.ipk
opkg install luci-theme-shadcn.ipk
# apk
uclient-fetch -O luci-theme-shadcn.apk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn-0.6.1-r20261031.apk
uclient-fetch -O luci-theme-shadcn.apk https://github.com/eamonxg/luci-theme-shadcn/releases/latest/download/luci-theme-shadcn-0.6.0-r20260920.apk
apk add --allow-untrusted luci-theme-shadcn.apk
```
@@ -1 +1 @@
"use strict";"require baseclass";"require ui";const ICON_MAP={status:"activity",system:"settings",network:"network",services:"layers",nas:"hard-drive",control:"sliders-horizontal",vpn:"shield",docker:"container",statistics:"chart-bar",nlbw:"gauge",asterisk:"phone",logout:"log-out",default:"layout-dashboard"},CACHE_KEY="shadcn.sidebar.cache",RECENTS_KEY="shadcn.palette.recents";return baseclass.extend({__init__(){ui.menu.load().then(e=>{this.renderSidebarChrome(),this.render(e),this.initUciIndicator(),this.cacheSidebar(),window.addEventListener("pagehide",()=>this.cacheSidebar()),document.dispatchEvent(new Event("shadcn-sidebar-ready"))})},cacheSidebar(){if(window.shadcnSuppressSidebarCache)return;const e=document.getElementById("sidebar"),t=document.getElementById("sidebar-nav");if(e&&t)try{sessionStorage.setItem(CACHE_KEY,JSON.stringify({v:1,lang:document.documentElement.lang||"",html:e.innerHTML,scroll:t.scrollTop||0}))}catch(e){}},_mediaBase(){const e=document.documentElement.getAttribute("data-shadcn-media"),t=document.getElementById("sidebar"),a=t&&t.getAttribute("data-media");return String(e||a||"/luci-static/shadcn").replace(/\/$/,"")},_sectionIcon(e,t){const a=String(e||"").toLowerCase(),s=ICON_MAP[a]||ICON_MAP.default;return this._iconFile(s,t)},_isLogoutMenuItem(e){const t=String(e&&null!=e.name?e.name:"").toLowerCase();return"logout"===t||t.endsWith("/logout")},_iconFile(e,t){const a=this._mediaBase();return E("img",{src:`${a}/icons/${e}.svg`,width:String(t),height:String(t),alt:"",loading:"lazy",class:"shadcn-icon"})},_resolveMenuBranch(e){const t=L.env.dispatchpath||[],a=L.env.requestpath||[],s=ui.menu.getChildren(e);let n=a.length&&a[0]||t.length&&t[0]||"admin",i=null;for(let e=0;e<s.length;e++)if(s[e].name===n){i=s[e];break}return i||1!==s.length||(i=s[0],n=i.name),i||(i=e),{branch:i,branchUrl:i.name||n}},renderSidebarChrome(){const e=document.getElementById("sidebar");if(!e)return;if("1"===e.getAttribute("data-shadcn-built")){const t=e.querySelector(".sidebar-brand-text"),a=e.getAttribute("data-hostname");return void(t&&a&&t.textContent!==a&&(t.textContent=a))}e.setAttribute("data-shadcn-built","1");const t=this._mediaBase(),a=e.getAttribute("data-hostname")||"Shadcn";for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(E("div",{class:"sidebar-header"},[E("a",{class:"sidebar-brand",href:"/"},[E("img",{class:"sidebar-logo",src:`${t}/images/logo.svg`,alt:"Shadcn",width:"24",height:"24"}),E("span",{class:"sidebar-brand-text"},[a])]),E("button",{id:"sidebar-toggle-btn",class:"sidebar-toggle",type:"button","aria-label":_("Navigation")},[E("span",{class:"icon-collapse"},[this._iconFile("panel-left-close",16)]),E("span",{class:"icon-expand"},[this._iconFile("panel-left-open",16)])])])),e.appendChild(E("nav",{class:"sidebar-nav",id:"sidebar-nav"})),e.appendChild(E("div",{class:"sidebar-footer",id:"sidebar-footer",hidden:""}))},render(e){const{branch:t,branchUrl:a}=this._resolveMenuBranch(e);this.tree=e,this.branch=t,this.branchUrl=a,this.renderSidebarNav(t,a),this.renderBreadcrumb(t,a),this.initPalette(t,a),this.renderTabs(e)},renderTabs(e){const t=L.env.dispatchpath||[],a=document.getElementById("tabmenu");a&&(a.innerHTML="",a.style.display="none");let s=e,n="";if(t.length>=3){for(var i=0;i<3&&s;i++){const e=t[i];if(!s.children||!s.children[e])break;s=s.children[e],n=n+(n?"/":"")+e}s&&this.renderTabMenu(s,n)}},syncRoute(){if(!this.tree)return;const e=document.getElementById("sidebar");if(e){e.querySelectorAll(".active").forEach(e=>e.classList.remove("active"));const t=new URL(L.url(...(L.env.dispatchpath||[]).slice(0,3)),location.href).pathname;let a=null,s=-1;if(e.querySelectorAll("a.sidebar-sub-link, a.sidebar-nav-parent, a.sidebar-logout").forEach(e=>{const n=e.pathname;(t===n||t.startsWith(n+"/"))&&n.length>s&&(a=e,s=n.length)}),a){const t=a.closest(".sidebar-sub-item");if(t){t.classList.add("active");const s=a.closest(".sidebar-accordion-item");s&&(s.classList.add("active"),"true"!==e.getAttribute("data-collapsed")&&e.querySelectorAll(".sidebar-accordion-item").forEach(e=>e.setAttribute("data-open",e===s?"true":"false")))}else a.classList.contains("sidebar-logout")?a.classList.add("active"):a.closest(".sidebar-nav-item")?.classList.add("active")}}this.renderBreadcrumb(this.branch,this.branchUrl),this.renderTabs(this.tree)},closeSurfaces(){this.closePalette(),window.ShadcnSidebar?.closeDrawer?.(),window.ShadcnSidebar?._hideCollapsedPopover?.()},renderSidebarNav(e,t){const a=document.getElementById("sidebar-nav"),s=document.getElementById("sidebar-footer");if(!a)return;const n=ui.menu.getChildren(e),i=L.env.dispatchpath||[],r="1"===document.getElementById("sidebar")?.getAttribute("data-shadcn-restored"),l=r?Array.from(a.querySelectorAll('.sidebar-accordion-item[data-open="true"]'),e=>e.getAttribute("data-section")):null,o=r?a.scrollTop:0;a.innerHTML="",s&&(s.innerHTML="",s.hidden=!0),n.forEach(e=>{if(this._isLogoutMenuItem(e)&&s){const a=i[1]==e.name,n=this._sectionIcon(e.name,18);returLine truncated
"use strict";"require baseclass";"require ui";const ICON_MAP={status:"activity",system:"settings",network:"network",services:"layers",nas:"hard-drive",control:"sliders-horizontal",vpn:"shield",docker:"container",statistics:"chart-bar",nlbw:"gauge",asterisk:"phone",logout:"log-out",default:"layout-dashboard"},CACHE_KEY="shadcn.sidebar.cache",RECENTS_KEY="shadcn.palette.recents";return baseclass.extend({__init__(){this.renderSidebarChrome(),this.initUciIndicator(),document.dispatchEvent(new Event("shadcn-sidebar-ready")),ui.menu.load().then(e=>{this.render(e),this.cacheSidebar(),window.addEventListener("pagehide",()=>this.cacheSidebar())})},cacheSidebar(){if(window.shadcnSuppressSidebarCache)return;const e=document.getElementById("sidebar"),t=document.getElementById("sidebar-nav");if(e&&t)try{sessionStorage.setItem(CACHE_KEY,JSON.stringify({v:2,stamp:document.body.dataset.navStamp,html:e.innerHTML,scroll:t.scrollTop||0}))}catch(e){}},_mediaBase(){const e=document.documentElement.getAttribute("data-shadcn-media"),t=document.getElementById("sidebar"),a=t&&t.getAttribute("data-media");return String(e||a||"/luci-static/shadcn").replace(/\/$/,"")},_sectionIcon(e,t){const a=String(e||"").toLowerCase(),s=ICON_MAP[a]||ICON_MAP.default;return this._iconFile(s,t)},_isLogoutMenuItem(e){const t=String(e&&null!=e.name?e.name:"").toLowerCase();return"logout"===t||t.endsWith("/logout")},_iconFile(e,t){const a=this._mediaBase();return E("img",{src:`${a}/icons/${e}.svg`,width:String(t),height:String(t),alt:"",loading:"lazy",class:"shadcn-icon"})},_resolveMenuBranch(e){const t=L.env.dispatchpath||[],a=L.env.requestpath||[],s=ui.menu.getChildren(e);let n=a.length&&a[0]||t.length&&t[0]||"admin",i=null;for(let e=0;e<s.length;e++)if(s[e].name===n){i=s[e];break}return i||1!==s.length||(i=s[0],n=i.name),i||(i=e),{branch:i,branchUrl:i.name||n}},renderSidebarChrome(){const e=document.getElementById("sidebar");if(!e)return;if("1"===e.getAttribute("data-shadcn-built")){const t=e.querySelector(".sidebar-brand-text"),a=e.getAttribute("data-hostname");return void(t&&a&&t.textContent!==a&&(t.textContent=a))}e.setAttribute("data-shadcn-built","1");const t=this._mediaBase(),a=e.getAttribute("data-hostname")||"Shadcn";for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(E("div",{class:"sidebar-header"},[E("a",{class:"sidebar-brand",href:"/"},[E("img",{class:"sidebar-logo",src:`${t}/images/logo.svg`,alt:"Shadcn",width:"24",height:"24"}),E("span",{class:"sidebar-brand-text"},[a])]),E("button",{id:"sidebar-toggle-btn",class:"sidebar-toggle",type:"button","aria-label":_("Navigation")},[E("span",{class:"icon-collapse"},[this._iconFile("panel-left-close",16)]),E("span",{class:"icon-expand"},[this._iconFile("panel-left-open",16)])])])),e.appendChild(E("nav",{class:"sidebar-nav",id:"sidebar-nav"})),e.appendChild(E("div",{class:"sidebar-footer",id:"sidebar-footer",hidden:""}))},render(e){const{branch:t,branchUrl:a}=this._resolveMenuBranch(e);this.tree=e,this.branch=t,this.branchUrl=a,this.renderSidebarNav(t,a),this.renderBreadcrumb(t,a),this.initPalette(t,a),this.renderTabs(e)},renderTabs(e){const t=L.env.dispatchpath||[],a=document.getElementById("tabmenu");a&&(a.innerHTML="",a.style.display="none");let s=e,n="";if(t.length>=3){for(var i=0;i<3&&s;i++){const e=t[i];if(!s.children||!s.children[e])break;s=s.children[e],n=n+(n?"/":"")+e}s&&this.renderTabMenu(s,n)}},syncRoute(){if(!this.tree)return;const e=document.getElementById("sidebar");if(e){e.querySelectorAll(".active").forEach(e=>e.classList.remove("active"));const t=new URL(L.url(...(L.env.dispatchpath||[]).slice(0,3)),location.href).pathname;let a=null,s=-1;if(e.querySelectorAll("a.sidebar-sub-link, a.sidebar-nav-parent, a.sidebar-logout").forEach(e=>{const n=e.pathname;(t===n||t.startsWith(n+"/"))&&n.length>s&&(a=e,s=n.length)}),a){const t=a.closest(".sidebar-sub-item");if(t){t.classList.add("active");const s=a.closest(".sidebar-accordion-item");s&&(s.classList.add("active"),"true"!==e.getAttribute("data-collapsed")&&e.querySelectorAll(".sidebar-accordion-item").forEach(e=>e.setAttribute("data-open",e===s?"true":"false")))}else a.classList.contains("sidebar-logout")?a.classList.add("active"):a.closest(".sidebar-nav-item")?.classList.add("active")}}this.renderBreadcrumb(this.branch,this.branchUrl),this.renderTabs(this.tree)},closeSurfaces(){this.closePalette(),window.ShadcnSidebar?.closeDrawer?.(),window.ShadcnSidebar?._hideCollapsedPopover?.()},renderSidebarNav(e,t){const a=document.getElementById("sidebar-nav"),s=document.getElementById("sidebar-footer");if(!a)return;const n=ui.menu.getChildren(e),i=L.env.dispatchpath||[],r="1"===document.getElementById("sidebar")?.getAttribute("data-shadcn-restored"),l=r?Array.from(a.querySelectorAll('.sidebar-accordion-item[data-open="true"]'),e=>e.getAttribute("data-section")):null,o=r?a.scrollTop:0;a.innerHTML="",s&&(s.innerHTML="",s.hidden=!0),n.forEach(e=>{if(this._isLogoutMenuItem(e)&&s){const a=i[1]==e.name,n=this._sectionIcon(e.name,18);return Line truncated
@@ -1 +1 @@
"use strict";"require baseclass";"require ui";"require poll";"require rpc";const RT=window.L,RENDER_TIMEOUT=15e3,PATCH_ATTR="data-luci-patch",NODE_CSS_ATTR="data-luci-node-css",SHELL_ATTR="data-luci-shell",PROGRESS_DELAY=150,INSTANTIATE=/instantiateView\(\s*['"]([^'"]+)['"]/,TITLE_SLOT="\0",supported=()=>"object"==typeof navigation&&null!==navigation&&"function"==typeof navigation.addEventListener&&"function"==typeof NavigateEvent&&"intercept"in NavigateEvent.prototype,nodeWeight=e=>Math.min(e.order??9999,9999)+(e.auth?.login?1e4:0);function firstChild(e){let t=null;for(const[n,s]of Object.entries(e.children??{}))if(s.satisfied&&s.title&&"object"==typeof s.action)if("firstchild"===s.action?.type){if(t&&nodeWeight(t.node)<=nodeWeight(s))continue;firstChild(s)&&(t={name:n,node:s})}else s.firstchild_ineligible||(!t||nodeWeight(t.node)>nodeWeight(s))&&(t={name:n,node:s});return t}function resolve(e,t){let n=e,s=[],i=[],r=0;for(let o=0;o<32;o++){for(;r<t.length;){const e=n.children?.[t[r]];if(e&&!1===e.satisfied)return null;if(!e){if(!n.wildcard)return null;i=t.slice(r),r=t.length;break}n=e,s.push(t[r++])}const o=n.action?.type;if("alias"===o)t=[...String(n.action.path??"").split("/").filter(Boolean),...i],n=e,s=[],i=[],r=0;else{if("firstchild"!==o)return{node:n,path:s,args:i,request:t};{const e=firstChild(n);if(!e)return null;n=e.node,s.push(e.name)}}}return null}function readonlyAlong(e,t){const n=[];let s=e;for(const e of t)s=s?.children?.[e],s?.depends?.acl?.length&&n.push(s);return n.length>0&&n.every(e=>!0===e.readonly)}const actionOf=(e,t)=>t.length&&"object"==typeof e?.wildcardaction?e.wildcardaction:e?.action;function viewClass(e){return"view"===e?.type&&e.path?`view.${e.path.replace(/\//g,".")}`:null}const PRAGMA_HEAD=/^(?:\s*["'](?:use strict|require\s[^"']+)["'];)+/,PRAGMA=/["']require\s+([^\s"']+)/g;function pragmaDeps(e){return[...(e.slice(0,4096).match(PRAGMA_HEAD)?.[0]??"").matchAll(PRAGMA)].map(e=>e[1])}const moduleUrl=e=>`${RT.env.base_url}/${e.replace(/\./g,"/")}.js${RT.env.resource_version?`?v=${RT.env.resource_version}`:""}`,TITLE_SEPARATOR=/\s*[|\-–—:·]\s*\0|\0\s*[|\-–—:·]\s*/,patchPrefixes=e=>{const t=[];let n=null;for(const s of e)t.push(n=null==n?s:`${n}-${s}`);return t},patchAssetUrl=e=>{const t=document.body.dataset.assetVersion;return t?`${e}?v=${t}`:e},installedPatches=()=>(document.body.dataset.patches??"").split(/\s+/).filter(Boolean),patchRegistry=()=>window.luciPatches??={};let livePatches=new Set,pendingPatches=[];function applyPatches(e){const t=new Set(patchPrefixes(e)),n=RT.env.media,s=[];for(const e of installedPatches()){const i=e.replace(/\.(css|js)$/,""),r=t.has(i);if(e.endsWith(".css")){let e=document.querySelector(`link[${PATCH_ATTR}="${i}"]`);!e&&r?(e=E("link",{rel:"stylesheet",href:patchAssetUrl(`${n}/patches/${i}.css`),[PATCH_ATTR]:i}),document.head.appendChild(e)):e&&(e.disabled=!r)}else r&&s.push(i)}return s}function mountPatches(e){const t=patchRegistry();livePatches=new Set(e);for(const n of e)if(document.querySelector(`script[${PATCH_ATTR}="${n}"]`))t[n]?.mount?.();else{const e=E("script",{src:patchAssetUrl(`${RT.env.media}/patches/${n}.js`),[PATCH_ATTR]:n});e.addEventListener("load",()=>{livePatches.has(n)||patchRegistry()[n]?.unmount?.()}),document.head.appendChild(e)}}function unmountPatches(){livePatches=new Set;const e=patchRegistry();for(const t in e)e[t].unmount?.()}return document.addEventListener("luci-navigate",e=>{unmountPatches(),pendingPatches=applyPatches(e.detail.request)}),document.addEventListener("luci-navigated",()=>{mountPatches(pendingPatches),pendingPatches=[]}),baseclass.extend({__init__(){if(!supported()||!RT?.env?.scriptname)return;const e=this.contract();if(e.length)return void console.warn(`luci-router: luci-base surface missing, staying MPA: ${e.join(", ")}`);this.gen=0,this.seen=new Set,this.warmed=new Set,this.inflight=Promise.resolve(),this.intervals=new Set,this.scrolls=new Map,this.knownSheets=new WeakSet(this.sheets().filter(e=>e.hasAttribute(SHELL_ATTR)||e.hasAttribute(PATCH_ATTR)||e.hasAttribute(NODE_CSS_ATTR))),this.hookIntervals(),this.hookListeners(),this.hookSession(),this.hookVisibility();const t=RT.env.media.split("/").filter(Boolean).pop();Promise.all([ui.menu.load(),RT.require(`menu-${t}`).catch(()=>null)]).then(([e,t])=>{this.tree=e,this.menu=t;const n=this.initialRoute();n&&(document.querySelector('script[type="speculationrules"]')?.remove(),this.status=document.body.appendChild(E("div",{id:"luci-nav-status",role:"status","aria-live":"polite","aria-atomic":"true"})),navigation.addEventListener("navigate",e=>this.onNavigate(e)),document.addEventListener("pointerover",e=>this.onIntent(e)),document.addEventListener("pointerdown",e=>this.onIntent(e)),document.addEventListener("focusin",e=>this.onIntent(e)),this.watchInitialRender(n))})},contract(){const e={L:RT,rpc:rpc,poll:poll,ui:ui,window:window};return["L.view","L.require","L.dom.content","L.env.base_url","L.env.resource","L.env.media","L.Request.addIntercLine truncated
"use strict";"require baseclass";"require ui";"require poll";"require rpc";const RT=window.L,RENDER_TIMEOUT=15e3,PATCH_ATTR="data-luci-patch",NODE_CSS_ATTR="data-luci-node-css",SHELL_ATTR="data-luci-shell",PROGRESS_DELAY=150,INTENT_DELAY=65,WARM_CONCURRENCY=2,WARM_TIMEOUT=5e3,INSTANTIATE=/instantiateView\(\s*['"]([^'"]+)['"]/,TITLE_SLOT="\0",supported=()=>"object"==typeof navigation&&null!==navigation&&"function"==typeof navigation.addEventListener&&"function"==typeof NavigateEvent&&"intercept"in NavigateEvent.prototype,nodeWeight=e=>Math.min(e.order??9999,9999)+(e.auth?.login?1e4:0);function firstChild(e){let t=null;for(const[s,n]of Object.entries(e.children??{}))if(n.satisfied&&n.title&&"object"==typeof n.action)if("firstchild"===n.action?.type){if(t&&nodeWeight(t.node)<=nodeWeight(n))continue;firstChild(n)&&(t={name:s,node:n})}else n.firstchild_ineligible||(!t||nodeWeight(t.node)>nodeWeight(n))&&(t={name:s,node:n});return t}function resolve(e,t){let s=e,n=[],i=[],r=0;for(let o=0;o<32;o++){for(;r<t.length;){const e=s.children?.[t[r]];if(e&&!1===e.satisfied)return null;if(!e){if(!s.wildcard)return null;i=t.slice(r),r=t.length;break}s=e,n.push(t[r++])}const o=s.action?.type;if("alias"===o)t=[...String(s.action.path??"").split("/").filter(Boolean),...i],s=e,n=[],i=[],r=0;else{if("firstchild"!==o)return{node:s,path:n,args:i,request:t};{const e=firstChild(s);if(!e)return null;s=e.node,n.push(e.name)}}}return null}function readonlyAlong(e,t){const s=[];let n=e;for(const e of t)n=n?.children?.[e],n?.depends?.acl?.length&&s.push(n);return s.length>0&&s.every(e=>!0===e.readonly)}const actionOf=(e,t)=>t.length&&"object"==typeof e?.wildcardaction?e.wildcardaction:e?.action;function viewClass(e){return"view"===e?.type&&e.path?`view.${e.path.replace(/\//g,".")}`:null}const BUILTIN_MODULES=new Set(["baseclass","dom","poll","request","session","view"]),MODULE_NAME=/^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/,PRAGMA=/(?:\s|;|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*(["'])(use[ \t]+strict|require[ \t]+[^"'\r\n]+)\1/y;function pragmaDeps(e){const t=e.slice(0,4096),s=[];PRAGMA.lastIndex=0;for(let e;e=PRAGMA.exec(t);){const t=/^require[ \t]+(\S+)(?:[ \t]+as[ \t]+[a-zA-Z_]\S*)?$/.exec(e[2]);if(t)MODULE_NAME.test(t[1])&&s.push(t[1]);else if(!/^use[ \t]+strict$/.test(e[2]))break}return s}const moduleUrl=e=>`${RT.env.base_url}/${e.replace(/\./g,"/")}.js${RT.env.resource_version?`?v=${RT.env.resource_version}`:""}`,TITLE_SEPARATOR=/\s*[|\-–—:·]\s*\0|\0\s*[|\-–—:·]\s*/,patchPrefixes=e=>{const t=[];let s=null;for(const n of e)t.push(s=null==s?n:`${s}-${n}`);return t},patchAssetUrl=e=>{const t=document.body.dataset.assetVersion;return t?`${e}?v=${t}`:e},installedPatches=()=>(document.body.dataset.patches??"").split(/\s+/).filter(Boolean),patchRegistry=()=>window.luciPatches??={};let livePatches=new Set,pendingPatches=[];function applyPatches(e){const t=new Set(patchPrefixes(e)),s=RT.env.media,n=[];for(const e of installedPatches()){const i=e.replace(/\.(css|js)$/,""),r=t.has(i);if(e.endsWith(".css")){let e=document.querySelector(`link[${PATCH_ATTR}="${i}"]`);!e&&r?(e=E("link",{rel:"stylesheet",href:patchAssetUrl(`${s}/patches/${i}.css`),[PATCH_ATTR]:i}),document.head.appendChild(e)):e&&(e.disabled=!r)}else r&&n.push(i)}return n}function mountPatches(e){const t=patchRegistry();livePatches=new Set(e);for(const s of e)if(document.querySelector(`script[${PATCH_ATTR}="${s}"]`))t[s]?.mount?.();else{const e=E("script",{src:patchAssetUrl(`${RT.env.media}/patches/${s}.js`),[PATCH_ATTR]:s});e.addEventListener("load",()=>{livePatches.has(s)||patchRegistry()[s]?.unmount?.()}),document.head.appendChild(e)}}function unmountPatches(){livePatches=new Set;const e=patchRegistry();for(const t in e)e[t].unmount?.()}return document.addEventListener("luci-navigate",e=>{unmountPatches(),pendingPatches=applyPatches(e.detail.request)}),document.addEventListener("luci-navigated",()=>{mountPatches(pendingPatches),pendingPatches=[]}),baseclass.extend({__init__(){if(!supported()||!RT?.env?.scriptname)return;const e=this.contract();if(e.length)return void console.warn(`luci-router: luci-base surface missing, staying MPA: ${e.join(", ")}`);this.gen=0,this.seen=new Set,this.warmed=new Map,this.warmQueue=[],this.warmActive=0,this.warmEpoch=0,this.inflight=Promise.resolve(),this.intervals=new Set,this.scrolls=new Map,this.knownSheets=new WeakSet(this.sheets().filter(e=>e.hasAttribute(SHELL_ATTR)||e.hasAttribute(PATCH_ATTR)||e.hasAttribute(NODE_CSS_ATTR))),this.hookIntervals(),this.hookListeners(),this.hookSession(),this.hookVisibility();const t=RT.env.media.split("/").filter(Boolean).pop();Promise.all([ui.menu.load(),RT.require(`menu-${t}`).catch(()=>null)]).then(([e,t])=>{this.tree=e,this.menu=t;const s=this.initialRoute();s&&(document.querySelector('script[type="speculationrules"]')?.remove(),this.status=document.body.appendChild(E("div",{id:"luci-nav-status",role:"status","aria-live":"polite","aria-atomic":"true"})),navigation.addEventListener("navigate",e=>thiLine truncated
@@ -13,9 +13,8 @@
L.require('sidebar-shadcn').then(function (m) {
window.ShadcnSidebar = m;
});
L.require('menu-shadcn').then(function () {
if (window.navigation) L.require('router-shadcn');
});
L.require('menu-shadcn');
if (window.navigation) L.require('router-shadcn');
</script>
{% endif %}
</body>
@@ -49,6 +49,9 @@
<title>{{ striptags(((boardinfo && boardinfo.hostname) ? boardinfo.hostname : '?') + ((dispatched && dispatched.title) ? (' | ' + _(dispatched.title)) : '')) }}</title>
<script>
(function () {
{% if (blank_page): %}
try { sessionStorage.removeItem('shadcn.sidebar.cache'); } catch (e) {}
{% endif %}
var theme = localStorage.getItem('shadcn.theme') || 'device';
var dark = theme === 'dark' || (theme === 'device' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-darkmode', dark ? 'true' : 'false');
@@ -83,7 +86,7 @@
<script src="{{ resource }}/cbi.js"></script>
</head>
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-asset-version="{# PKG_VERSION #}"{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}>
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-asset-version="{# PKG_VERSION #}"{% if (!blank_page): %} data-nav-stamp="{{ entityencode(join(':', [dispatcher.lang, ctx?.authuser ?? '', ctx?.authsession ?? '']), true) }}:{# PKG_VERSION #}"{% endif %}{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}>
{% if (!blank_page): %}
<div
@@ -112,7 +115,10 @@
if (!raw) return;
var cache = JSON.parse(raw);
var sb = document.getElementById('sidebar');
if (!sb || !cache || cache.v !== 1 || cache.lang !== document.documentElement.lang) return;
// The HTML belongs to one user/session and asset version. Follow
// ui.menu.flushCache() too: package changes invalidate its menu.
var luci = Object.values(JSON.parse(sessionStorage.getItem('luci-session-store')) || {})[0];
if (!sb || !cache || cache.v !== 2 || cache.stamp !== document.body.dataset.navStamp || !luci || !luci.menu) return;
sb.innerHTML = cache.html;
sb.setAttribute('data-shadcn-built', '1');
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise
PKG_VERSION:=2026.10.25
PKG_RELEASE:=48
PKG_VERSION:=2026.11.0
PKG_RELEASE:=49
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip
-80
View File
@@ -1,80 +0,0 @@
# SPDX-License-Identifier: GPL-2.0
#
# Copyright (C) 2023-2025 muink <https://github.com/muink>
# Copyright (C) 2015-2017 wongsyrone
include $(TOPDIR)/rules.mk
PKG_NAME:=Toolkit
PKG_UPSTREAM_VERSION:=0.4.0.2
PKG_UPSTREAM_GITHASH:=631954eac283e38822c47e3122e9e2a3ac5984c6
PKG_VERSION:=$(PKG_UPSTREAM_VERSION)~$(call version_abbrev,$(PKG_UPSTREAM_GITHASH))
PKG_RELEASE:=1
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/muink/Toolkit.git
PKG_SOURCE_VERSION:=$(PKG_UPSTREAM_GITHASH)
PKG_MIRROR_HASH:=skip
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_UPSTREAM_VERSION)
PKG_SOURCE:=$(PKG_SOURCE_SUBDIR)-$(PKG_SOURCE_VERSION).tar.gz
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR)
PKG_LICENSE:=GPL-2.0
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=Chengr28 <chengr28@twitter>
PKG_USE_MIPS16:=0
PKG_BUILD_PARALLEL:=1
CMAKE_INSTALL:=1
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
TARGET_CFLAGS += $(FPIC)
CMAKE_OPTIONS += \
-DPLATFORM_OPENWRT=ON
# Note: GCC 4.6 and 4.8 dont have complete C++11 support
# Please use GCC 4.9 or higher to compile
define Package/Toolkit
SECTION:=net
CATEGORY:=Network
TITLE:=A useful and powerful toolkit (DNSPing+FileHash)
URL:=https://github.com/chengr28/Toolkit
DEPENDS:=+libstdcpp \
@GCC_VERSION_4_6:BROKEN @GCC_VERSION_4_8:BROKEN
endef
define Package/Toolkit/config
if PACKAGE_Toolkit
config PACKAGE_Toolkit_advancedoptions
bool "Use advanced compile options, see Makefile for details."
default n
endif
endef
define Package/Toolkit/description
A network util can ping with DNS request.
endef
# Some advanced compile flags for expert
ifneq ($(CONFIG_PACKAGE_Toolkit_advancedoptions),)
# Try to reduce binary size
TARGET_CFLAGS += -ffunction-sections -fdata-sections
TARGET_LDFLAGS += -Wl,--gc-sections
# Use Link time optimization
TARGET_CFLAGS += -flto
TARGET_LDFLAGS += -Wl,-flto
endif
define Package/Toolkit/install
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/{DNSPing,FileHash} $(1)/usr/sbin/
endef
$(eval $(call BuildPackage,Toolkit))
-61
View File
@@ -1,61 +0,0 @@
Toolkit for OpenWrt/LEDE
===
简介
---
本项目是 [Toolkit][1] 运行在 OpenWrt/LEDE 上的软件包,目前包含 DNSPing 和 FileHash
当前版本: 0.4.0.2-2
编译
---
- 从 OpenWrt/LEDE 的 [SDK][S] 编译
```bash
# 以 ar71xx 平台为例,此处文件名为示例,仅供参考,请以实际文件名为准
# 有对应平台的 SDK 即可编译软件包,不仅限于 ar71xx
tar xjf OpenWrt-SDK-ar71xx-for-linux-x86_64-gcc-4.8-linaro_uClibc-0.9.33.2.tar.bz2
# 进入 SDK 根目录
cd OpenWrt-SDK-ar71xx-*
# 首先验证 SDK 是否需要 ccache
cat .config | grep CONFIG_CCACHE
# 如果返回结果为 "y",则需要使用系统软件包管理器,如 yum、apt-get,安装 ccache
# 获取 Makefile
git clone --depth 1 --branch master --single-branch https://github.com/wongsyrone/openwrt-Toolkit.git package/Toolkit
# 选择要编译的包 Network -> Toolkit 并进行个人定制,或者保持默认
# 这时根据提供的选项确认依赖已经被选中
make menuconfig
# 开始编译
make package/Toolkit/compile V=99
# 编译结束之后从 bin 文件夹复制本程序的 ipk 文件到设备中,使用 opkg 进行安装
```
- 从 OpenWrt/LEDE 的代码树编译
也可将本项目文件夹命名为 `Toolkit` 直接放置于 OpenWrt/LEDE 代码树的 `package` 文件夹下,之后按照编译的正常步骤进行,最后可在 bin 目录中找到编译好的软件包。下面简述编译步骤
```bash
# 获取OpenWrt/LEDE代码树,根据需求选择稳定版(如Chaos Calmer 15.05)或开发版Trunk
# 如果是 Trunk 使用
git clone git://git.openwrt.org/openwrt.git
# 如果是 Chaos Calmer 15.05 稳定版使用
git clone git://git.openwrt.org/15.05/openwrt.git
# 进入代码树根目录
cd openwrt
# 获取 Makefile
git clone --depth 1 --branch master --single-branch https://github.com/wongsyrone/openwrt-Toolkit.git package/Toolkit
# 首先选择目标平台以及设备型号
# 接下来选择要编译的包 Network -> Toolkit 并进行个人定制,或者保持默认
make menuconfig
# 如果只想编译 Toolkit 使用
make package/Toolkit/{prepare,compile} V=99
# 如果想编译集成好 Toolkit 的固件使用
make V=99
```
----------
[1]: https://github.com/muink/Toolkit
[2]: https://github.com/muink/Toolkit/tree/master/Documents
[S]: http://wiki.openwrt.org/doc/howto/obtain.firmware.sdk
@@ -1,33 +0,0 @@
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,7 +18,7 @@
# CMake minimum version
-cmake_minimum_required(VERSION 3.1)
+cmake_minimum_required(VERSION 3.5)
# Sub directories
add_subdirectory(Source/DNSPing)
--- a/Source/DNSPing/CMakeLists.txt
+++ b/Source/DNSPing/CMakeLists.txt
@@ -18,7 +18,7 @@
# CMake minimum version
-cmake_minimum_required(VERSION 3.1)
+cmake_minimum_required(VERSION 3.5)
# Project name, build type and list all source files.
project(DNSPing)
--- a/Source/FileHash/CMakeLists.txt
+++ b/Source/FileHash/CMakeLists.txt
@@ -18,7 +18,7 @@
# CMake minimum version
-cmake_minimum_required(VERSION 3.1)
+cmake_minimum_required(VERSION 3.5)
# Project name, build type, list all source files and sub directories.
project(FileHash)
+1
Submodule openwrt-fastfetch added at 287b4f7c95.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Anya Lin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-43
View File
@@ -1,43 +0,0 @@
# SPDX-License-Identifier: MIT
#
# Copyright (C) 2025 Anya Lin <hukk1996@gmail.com>
include $(TOPDIR)/rules.mk
PKG_NAME:=fastfetch
PKG_VERSION:=2.70.0
PKG_RELEASE:=17
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/fastfetch-cli/fastfetch/tar.gz/$(PKG_VERSION)?
PKG_HASH:=skip
PKG_MAINTAINER:=Anya Lin <hukk1996@gmail.com>
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
define Package/fastfetch
SECTION:=utils
CATEGORY:=Utilities
TITLE:=Fetch and display system information
URL:=https://github.com/fastfetch-cli/fastfetch
DEPENDS+=
endef
define Package/fastfetch/description
Fastfetch is a neofetch-like tool for fetching
system information and displaying it prettily.
endef
define Package/fastfetch/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/fastfetch $(1)/usr/bin/
endef
$(eval $(call BuildPackage,fastfetch))
-27
View File
@@ -1,27 +0,0 @@
# openwrt-fastfetch
## Releases
You can find the prebuilt-ipks [here](https://fantastic-packages.github.io/releases/)
## Build
```shell
# Take the x86_64 platform as an example
tar xjf openwrt-sdk-23.05.5-x86-64_gcc-8.4.0_musl.Linux-x86_64.tar.xz
# Go to the SDK root dir
cd OpenWrt-sdk-*-x86_64_*
# First run to generate a .config file
make menuconfig
./scripts/feeds update -a
./scripts/feeds install -a
# Get Makefile
git clone --depth 1 --branch master --single-branch --no-checkout https://github.com/muink/openwrt-fastfetch.git package/fastfetch
pushd package/fastfetch
umask 022
git checkout
popd
# Select the package Utilities -> fastfetch
make menuconfig
# Start compiling
make package/fastfetch/compile V=s BUILD_LOG=y -j$(nproc)
```
+2 -2
View File
@@ -9,7 +9,7 @@ PKG_NAME:=natmapt
PKG_UPSTREAM_VERSION:=20260214
PKG_UPSTREAM_GITHASH:=
PKG_VERSION:=$(PKG_UPSTREAM_VERSION)$(if $(PKG_UPSTREAM_GITHASH),~$(call version_abbrev,$(PKG_UPSTREAM_GITHASH)))
PKG_RELEASE:=15
PKG_RELEASE:=16
SCRIPTS_VERSION:=0.2026.01.24
PKG_SOURCE_SUBDIR:=$(PKG_UPSTREAM_NAME)-$(PKG_UPSTREAM_VERSION)
@@ -23,7 +23,7 @@ PKG_SOURCE:=$(PKG_SOURCE_SUBDIR).tar.xz
else
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/heiher/natmap.git
PKG_SOURCE_VERSION:=a56329a9964441014abcdd5f379fb9f6133aba1c
PKG_SOURCE_VERSION:=b24d590cb413639c0d17783b7377367d398bd996
PKG_MIRROR_HASH:=skip
PKG_SOURCE:=$(PKG_SOURCE_SUBDIR)-$(PKG_SOURCE_VERSION).tar.gz