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:
CyberMind-FR 2026-03-11 16:47:09 +01:00
parent bde9c41563
commit 1ac3c4e8c0
5 changed files with 473 additions and 0 deletions

View 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

View File

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

View File

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

View File

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

View File

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