feat(metacatalog): Add LuCI dashboard (Phase 2)
New luci-app-metacatalog package: - RPCD backend with 10 methods (list_entries, list_books, search, etc.) - ACL permissions for read/write access - Menu entry under SecuBox - KISS-themed dashboard with: - Stats chips (entries, metablogs, streamlits, books) - Virtual books shelf with color-coded cards - Entry links to published content - Sync button for manual refresh - Landing page link Deployed and tested on router. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bde9c41563
commit
1ac3c4e8c0
29
package/secubox/luci-app-metacatalog/Makefile
Normal file
29
package/secubox/luci-app-metacatalog/Makefile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 CyberMind.fr - Gandalf
|
||||||
|
#
|
||||||
|
# LuCI app for Meta Cataloger
|
||||||
|
|
||||||
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
|
LUCI_TITLE:=LuCI Meta Cataloger
|
||||||
|
LUCI_DEPENDS:=+secubox-app-metacatalog
|
||||||
|
LUCI_PKGARCH:=all
|
||||||
|
|
||||||
|
PKG_VERSION:=1.0.0
|
||||||
|
PKG_RELEASE:=1
|
||||||
|
PKG_LICENSE:=Apache-2.0
|
||||||
|
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
|
||||||
|
|
||||||
|
include $(TOPDIR)/feeds/luci/luci.mk
|
||||||
|
|
||||||
|
define Package/luci-app-metacatalog/postinst
|
||||||
|
#!/bin/sh
|
||||||
|
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||||
|
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/*
|
||||||
|
/etc/init.d/rpcd restart
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
endef
|
||||||
|
|
||||||
|
# call BuildPackage - OpenWrt buildance
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
'use strict';
|
||||||
|
'require view';
|
||||||
|
'require rpc';
|
||||||
|
'require ui';
|
||||||
|
'require poll';
|
||||||
|
|
||||||
|
var callGetStats = rpc.declare({
|
||||||
|
object: 'luci.metacatalog',
|
||||||
|
method: 'get_stats',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callListBooks = rpc.declare({
|
||||||
|
object: 'luci.metacatalog',
|
||||||
|
method: 'list_books',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callListEntries = rpc.declare({
|
||||||
|
object: 'luci.metacatalog',
|
||||||
|
method: 'list_entries',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callSync = rpc.declare({
|
||||||
|
object: 'luci.metacatalog',
|
||||||
|
method: 'sync',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callSearch = rpc.declare({
|
||||||
|
object: 'luci.metacatalog',
|
||||||
|
method: 'search',
|
||||||
|
params: ['query'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return view.extend({
|
||||||
|
handleSync: function() {
|
||||||
|
return callSync().then(function(res) {
|
||||||
|
ui.addNotification(null, E('p', 'Sync started in background. Refresh in a few seconds.'), 'info');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
load: function() {
|
||||||
|
return Promise.all([
|
||||||
|
callGetStats(),
|
||||||
|
callListBooks(),
|
||||||
|
callListEntries()
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function(data) {
|
||||||
|
var stats = data[0] || {};
|
||||||
|
var booksData = data[1] || {};
|
||||||
|
var entriesData = data[2] || {};
|
||||||
|
|
||||||
|
var books = (booksData.books || []);
|
||||||
|
var entries = (entriesData.entries || []);
|
||||||
|
|
||||||
|
// Build entries lookup
|
||||||
|
var entriesMap = {};
|
||||||
|
entries.forEach(function(e) {
|
||||||
|
entriesMap[e.id] = e;
|
||||||
|
});
|
||||||
|
|
||||||
|
var view = E('div', { 'class': 'cbi-map' }, [
|
||||||
|
E('style', {}, [
|
||||||
|
'.mc-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }',
|
||||||
|
'.mc-title { font-size: 1.5rem; font-weight: bold; }',
|
||||||
|
'.mc-chips { display: flex; gap: 0.5rem; flex-wrap: wrap; }',
|
||||||
|
'.mc-chip { padding: 0.3rem 0.8rem; border-radius: 4px; font-size: 0.75rem; background: #1a1a2e; color: #fff; }',
|
||||||
|
'.mc-chip.fire { background: #ff0066; }',
|
||||||
|
'.mc-chip.wood { background: #00ff88; color: #000; }',
|
||||||
|
'.mc-chip.water { background: #0066ff; }',
|
||||||
|
'.mc-chip.metal { background: #cc00ff; }',
|
||||||
|
'.mc-actions { margin-left: auto; display: flex; gap: 0.5rem; }',
|
||||||
|
'.mc-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; margin-top: 1rem; }',
|
||||||
|
'.mc-book { background: #0a0c1a; border: 1px solid #222; border-left: 4px solid var(--book-color, #cc00ff); padding: 1rem; border-radius: 4px; }',
|
||||||
|
'.mc-book-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }',
|
||||||
|
'.mc-book-icon { font-size: 1.5rem; }',
|
||||||
|
'.mc-book-title { font-weight: bold; }',
|
||||||
|
'.mc-book-count { margin-left: auto; font-size: 0.7rem; color: #888; }',
|
||||||
|
'.mc-entries { display: flex; flex-direction: column; gap: 0.3rem; }',
|
||||||
|
'.mc-entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem; background: rgba(255,255,255,0.02); border-radius: 2px; }',
|
||||||
|
'.mc-entry:hover { background: rgba(255,255,255,0.05); }',
|
||||||
|
'.mc-entry-type { font-size: 0.55rem; padding: 0.1rem 0.3rem; border-radius: 2px; background: #cc00ff; color: #000; }',
|
||||||
|
'.mc-entry-type.metablog { background: #ff0066; color: #fff; }',
|
||||||
|
'.mc-entry-type.streamlit { background: #00ff88; color: #000; }',
|
||||||
|
'.mc-entry-name { font-size: 0.8rem; flex: 1; }',
|
||||||
|
'.mc-entry-link { font-size: 0.65rem; color: #0066ff; text-decoration: none; }',
|
||||||
|
'.mc-empty { color: #666; font-style: italic; font-size: 0.75rem; }',
|
||||||
|
'.mc-landing { margin-top: 1.5rem; padding: 1rem; background: #0a0c1a; border-radius: 4px; }',
|
||||||
|
'.mc-landing a { color: #00ffff; }'
|
||||||
|
].join('\n')),
|
||||||
|
|
||||||
|
E('div', { 'class': 'mc-header' }, [
|
||||||
|
E('span', { 'class': 'mc-title' }, 'Meta Cataloger'),
|
||||||
|
E('div', { 'class': 'mc-chips' }, [
|
||||||
|
E('span', { 'class': 'mc-chip fire' }, (stats.total_entries || 0) + ' Entries'),
|
||||||
|
E('span', { 'class': 'mc-chip wood' }, (stats.metablogs || 0) + ' MetaBlogs'),
|
||||||
|
E('span', { 'class': 'mc-chip water' }, (stats.streamlits || 0) + ' Streamlits'),
|
||||||
|
E('span', { 'class': 'mc-chip metal' }, (stats.books || 0) + ' Books')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'mc-actions' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-action',
|
||||||
|
'click': ui.createHandlerFn(this, 'handleSync')
|
||||||
|
}, 'Sync Now'),
|
||||||
|
E('a', {
|
||||||
|
'class': 'cbi-button cbi-button-neutral',
|
||||||
|
'href': '/metacatalog/',
|
||||||
|
'target': '_blank'
|
||||||
|
}, 'Landing Page')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Virtual Books shelf
|
||||||
|
var shelf = E('div', { 'class': 'mc-shelf' });
|
||||||
|
|
||||||
|
books.forEach(function(book) {
|
||||||
|
var bookEntries = (book.entries || []).map(function(eid) {
|
||||||
|
return entriesMap[eid];
|
||||||
|
}).filter(function(e) { return e; });
|
||||||
|
|
||||||
|
var bookDiv = E('div', { 'class': 'mc-book', 'style': '--book-color: ' + (book.color || '#cc00ff') }, [
|
||||||
|
E('div', { 'class': 'mc-book-head' }, [
|
||||||
|
E('span', { 'class': 'mc-book-icon' }, book.icon || ''),
|
||||||
|
E('span', { 'class': 'mc-book-title' }, book.name || book.id),
|
||||||
|
E('span', { 'class': 'mc-book-count' }, bookEntries.length + ' entries')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'mc-entries' },
|
||||||
|
bookEntries.length > 0 ?
|
||||||
|
bookEntries.slice(0, 8).map(function(entry) {
|
||||||
|
return E('div', { 'class': 'mc-entry' }, [
|
||||||
|
E('span', { 'class': 'mc-entry-type ' + entry.type }, entry.type),
|
||||||
|
E('span', { 'class': 'mc-entry-name' }, (entry.metadata && entry.metadata.title) || entry.name),
|
||||||
|
E('a', {
|
||||||
|
'class': 'mc-entry-link',
|
||||||
|
'href': entry.url,
|
||||||
|
'target': '_blank'
|
||||||
|
}, entry.domain)
|
||||||
|
]);
|
||||||
|
}).concat(
|
||||||
|
bookEntries.length > 8 ?
|
||||||
|
[E('div', { 'class': 'mc-empty' }, '+ ' + (bookEntries.length - 8) + ' more...')] : []
|
||||||
|
) :
|
||||||
|
[E('div', { 'class': 'mc-empty' }, 'No entries')]
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
shelf.appendChild(bookDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
view.appendChild(shelf);
|
||||||
|
|
||||||
|
// Landing page link
|
||||||
|
view.appendChild(E('div', { 'class': 'mc-landing' }, [
|
||||||
|
E('strong', {}, 'Public Landing Page: '),
|
||||||
|
E('a', { 'href': 'https://catalog.gk2.secubox.in/metacatalog/', 'target': '_blank' },
|
||||||
|
'https://catalog.gk2.secubox.in/metacatalog/')
|
||||||
|
]));
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# RPCD handler for Meta Cataloger
|
||||||
|
# Copyright (C) 2026 CyberMind.fr
|
||||||
|
|
||||||
|
. /lib/functions.sh
|
||||||
|
|
||||||
|
DATA_DIR="/srv/metacatalog"
|
||||||
|
ENTRIES_DIR="$DATA_DIR/entries"
|
||||||
|
INDEX_FILE="$DATA_DIR/index.json"
|
||||||
|
BOOKS_FILE="$DATA_DIR/books.json"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# HELPERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# METHODS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
list_entries() {
|
||||||
|
if [ -f "$INDEX_FILE" ]; then
|
||||||
|
cat "$INDEX_FILE"
|
||||||
|
else
|
||||||
|
echo '{"version":"1.0.0","entries":[]}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
list_books() {
|
||||||
|
if [ -f "$BOOKS_FILE" ]; then
|
||||||
|
cat "$BOOKS_FILE"
|
||||||
|
else
|
||||||
|
echo '{"version":"1.0.0","books":[]}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_entry() {
|
||||||
|
local id="$1"
|
||||||
|
local entry_file="$ENTRIES_DIR/$id.json"
|
||||||
|
|
||||||
|
if [ -f "$entry_file" ]; then
|
||||||
|
cat "$entry_file"
|
||||||
|
else
|
||||||
|
echo '{"error":"Entry not found"}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_book() {
|
||||||
|
local book_id="$1"
|
||||||
|
|
||||||
|
if [ -f "$BOOKS_FILE" ]; then
|
||||||
|
jsonfilter -i "$BOOKS_FILE" -e "@.books[@.id='$book_id']" 2>/dev/null || echo '{"error":"Book not found"}'
|
||||||
|
else
|
||||||
|
echo '{"error":"Books file not found"}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
search_catalog() {
|
||||||
|
local query="$1"
|
||||||
|
query=$(echo "$query" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
echo '{"results":['
|
||||||
|
local first=1
|
||||||
|
|
||||||
|
for f in "$ENTRIES_DIR"/*.json; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local content=$(cat "$f" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if echo "$content" | grep -q "$query"; then
|
||||||
|
[ $first -eq 0 ] && echo ","
|
||||||
|
cat "$f"
|
||||||
|
first=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ']}'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_stats() {
|
||||||
|
local total=$(ls -1 "$ENTRIES_DIR"/*.json 2>/dev/null | wc -l)
|
||||||
|
local metablogs=$(grep -l '"type": "metablog"' "$ENTRIES_DIR"/*.json 2>/dev/null | wc -l)
|
||||||
|
local streamlits=$(grep -l '"type": "streamlit"' "$ENTRIES_DIR"/*.json 2>/dev/null | wc -l)
|
||||||
|
local books_count=0
|
||||||
|
|
||||||
|
if [ -f "$BOOKS_FILE" ]; then
|
||||||
|
books_count=$(jsonfilter -i "$BOOKS_FILE" -e '@.books[*].id' 2>/dev/null | wc -l)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"total_entries": $total,
|
||||||
|
"metablogs": $metablogs,
|
||||||
|
"streamlits": $streamlits,
|
||||||
|
"books": $books_count,
|
||||||
|
"index_exists": $([ -f "$INDEX_FILE" ] && echo "true" || echo "false"),
|
||||||
|
"books_exists": $([ -f "$BOOKS_FILE" ] && echo "true" || echo "false")
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger_sync() {
|
||||||
|
# Run sync in background
|
||||||
|
/usr/sbin/metacatalogctl sync >/dev/null 2>&1 &
|
||||||
|
echo '{"status":"started","message":"Sync started in background"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger_scan() {
|
||||||
|
local source="$1"
|
||||||
|
if [ -n "$source" ]; then
|
||||||
|
/usr/sbin/metacatalogctl scan "$source" >/dev/null 2>&1 &
|
||||||
|
else
|
||||||
|
/usr/sbin/metacatalogctl scan >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
echo '{"status":"started","message":"Scan started in background"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
assign_entry_to_book() {
|
||||||
|
local entry_id="$1"
|
||||||
|
local book_id="$2"
|
||||||
|
local entry_file="$ENTRIES_DIR/$entry_id.json"
|
||||||
|
|
||||||
|
if [ ! -f "$entry_file" ]; then
|
||||||
|
echo '{"error":"Entry not found"}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read current books
|
||||||
|
local current_books=$(jsonfilter -i "$entry_file" -e '@.books[*]' 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
||||||
|
|
||||||
|
# Check if already assigned
|
||||||
|
if echo "$current_books" | grep -q "$book_id"; then
|
||||||
|
echo '{"status":"already_assigned"}'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add book to entry
|
||||||
|
if [ -z "$current_books" ]; then
|
||||||
|
sed -i "s/\"books\": \[\]/\"books\": [\"$book_id\"]/" "$entry_file"
|
||||||
|
else
|
||||||
|
sed -i "s/\"books\": \[/\"books\": [\"$book_id\",/" "$entry_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '{"status":"assigned"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_entry_from_book() {
|
||||||
|
local entry_id="$1"
|
||||||
|
local book_id="$2"
|
||||||
|
local entry_file="$ENTRIES_DIR/$entry_id.json"
|
||||||
|
|
||||||
|
if [ ! -f "$entry_file" ]; then
|
||||||
|
echo '{"error":"Entry not found"}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove book from array (simplified - just remove the string)
|
||||||
|
sed -i "s/\"$book_id\",//g; s/,\"$book_id\"//g; s/\"$book_id\"//g" "$entry_file"
|
||||||
|
|
||||||
|
echo '{"status":"removed"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# MAIN
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
list)
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"list_entries": {},
|
||||||
|
"list_books": {},
|
||||||
|
"get_entry": {"id": "str"},
|
||||||
|
"get_book": {"id": "str"},
|
||||||
|
"search": {"query": "str"},
|
||||||
|
"get_stats": {},
|
||||||
|
"sync": {},
|
||||||
|
"scan": {"source": "str"},
|
||||||
|
"assign": {"entry_id": "str", "book_id": "str"},
|
||||||
|
"unassign": {"entry_id": "str", "book_id": "str"}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
call)
|
||||||
|
case "$2" in
|
||||||
|
list_entries)
|
||||||
|
list_entries
|
||||||
|
;;
|
||||||
|
list_books)
|
||||||
|
list_books
|
||||||
|
;;
|
||||||
|
get_entry)
|
||||||
|
read -r input
|
||||||
|
id=$(echo "$input" | jsonfilter -e '@.id')
|
||||||
|
get_entry "$id"
|
||||||
|
;;
|
||||||
|
get_book)
|
||||||
|
read -r input
|
||||||
|
id=$(echo "$input" | jsonfilter -e '@.id')
|
||||||
|
get_book "$id"
|
||||||
|
;;
|
||||||
|
search)
|
||||||
|
read -r input
|
||||||
|
query=$(echo "$input" | jsonfilter -e '@.query')
|
||||||
|
search_catalog "$query"
|
||||||
|
;;
|
||||||
|
get_stats)
|
||||||
|
get_stats
|
||||||
|
;;
|
||||||
|
sync)
|
||||||
|
trigger_sync
|
||||||
|
;;
|
||||||
|
scan)
|
||||||
|
read -r input
|
||||||
|
source=$(echo "$input" | jsonfilter -e '@.source' 2>/dev/null)
|
||||||
|
trigger_scan "$source"
|
||||||
|
;;
|
||||||
|
assign)
|
||||||
|
read -r input
|
||||||
|
entry_id=$(echo "$input" | jsonfilter -e '@.entry_id')
|
||||||
|
book_id=$(echo "$input" | jsonfilter -e '@.book_id')
|
||||||
|
assign_entry_to_book "$entry_id" "$book_id"
|
||||||
|
;;
|
||||||
|
unassign)
|
||||||
|
read -r input
|
||||||
|
entry_id=$(echo "$input" | jsonfilter -e '@.entry_id')
|
||||||
|
book_id=$(echo "$input" | jsonfilter -e '@.book_id')
|
||||||
|
remove_entry_from_book "$entry_id" "$book_id"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo '{"error":"Unknown method"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"admin/secubox/metacatalog": {
|
||||||
|
"title": "Meta Cataloger",
|
||||||
|
"order": 55,
|
||||||
|
"action": {
|
||||||
|
"type": "view",
|
||||||
|
"path": "metacatalog/overview"
|
||||||
|
},
|
||||||
|
"depends": {
|
||||||
|
"acl": ["luci-app-metacatalog"],
|
||||||
|
"uci": {"metacatalog": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"luci-app-metacatalog": {
|
||||||
|
"description": "Grant access to Meta Cataloger",
|
||||||
|
"read": {
|
||||||
|
"ubus": {
|
||||||
|
"luci.metacatalog": [
|
||||||
|
"list_entries",
|
||||||
|
"list_books",
|
||||||
|
"get_entry",
|
||||||
|
"get_book",
|
||||||
|
"search",
|
||||||
|
"get_stats"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"ubus": {
|
||||||
|
"luci.metacatalog": [
|
||||||
|
"sync",
|
||||||
|
"scan",
|
||||||
|
"assign",
|
||||||
|
"unassign"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user