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