feat(plugins): add JSONForms-based plugin configuration UI (#4911)

* feat(plugins): add JSONForms schema for plugin configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance error handling by formatting validation errors with field names

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enforce required fields in config validation and improve error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* format JS code

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config schema validation and enhance manifest structure

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: refactor plugin config parsing and add unit tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config validation error message in Portuguese

* feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: resolve React Hooks linting issues in plugin UI components

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* format code

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting

Signed-off-by: Deluan <deluan@navidrome.org>

* address PR comments

Signed-off-by: Deluan <deluan@navidrome.org>

* fix flaky test

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance array layout and configuration handling with AJV defaults

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add error boundary for schema rendering and improve error messages

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: refine non-enum array control logic by utilizing JSONForms schema resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add error styling to ToggleEnabledSwitch for disabled state

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: adjust label positioning and styling in SchemaConfigEditor for improved layout

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement outlined input controls renderers to replace custom fragile CSS

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: remove margin from last form control inside array items for better spacing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance AJV error handling to transform required errors for field-level validation

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: set default value for User Tokens in manifest.json to improve user experience

Signed-off-by: Deluan <deluan@navidrome.org>

* format

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add margin to outlined input controls for improved spacing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: remove redundant margin rule for last form control in array items

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: adjust font size of label elements in SchemaConfigEditor for improved readability

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Deluan Quintão
2026-01-19 20:51:00 -05:00
committed by GitHub
parent 66474fc9f4
commit f1e75c40dc
40 changed files with 5430 additions and 2007 deletions
+81 -43
View File
@@ -25,6 +25,14 @@ const (
// ID for the reconnection schedule
reconnectScheduleID = "crypto-ticker-reconnect"
// Config keys (must match manifest.json schema property names)
symbolsKey = "symbols"
reconnectDelayKey = "reconnectDelay"
logPricesKey = "logPrices"
// Default values
defaultReconnectDelay = 5
)
// CoinbaseSubscription message structure
@@ -74,36 +82,67 @@ var (
func (p *cryptoTickerPlugin) OnInit() error {
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH" // Default tickers
}
tickers := parseTickerSymbols(tickerConfig)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
// Get ticker configuration from JSON schema config
symbols := getSymbols()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols))
// Connect to WebSocket
// Errors won't fail init - reconnect logic will handle it
return connectAndSubscribe(tickers)
return connectAndSubscribe(symbols)
}
// parseTickerSymbols parses a comma-separated list of ticker symbols
func parseTickerSymbols(tickerConfig string) []string {
parts := strings.Split(tickerConfig, ",")
tickers := make([]string, 0, len(parts))
for _, ticker := range parts {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
// Add -USD suffix if not present
if !strings.Contains(ticker, "-") {
ticker = ticker + "-USD"
}
tickers = append(tickers, ticker)
// getSymbols reads the symbols array from config
func getSymbols() []string {
defaultSymbols := []string{"BTC-USD"}
symbolsJSON, ok := pdk.GetConfig(symbolsKey)
if !ok || symbolsJSON == "" {
return defaultSymbols
}
return tickers
var symbols []string
if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err))
return defaultSymbols
}
if len(symbols) == 0 {
return defaultSymbols
}
// Normalize symbols - add -USD suffix if not present
for i, s := range symbols {
s = strings.TrimSpace(s)
if !strings.Contains(s, "-") {
symbols[i] = s + "-USD"
} else {
symbols[i] = s
}
}
return symbols
}
// getReconnectDelay reads the reconnect delay from config
func getReconnectDelay() int32 {
delayStr, ok := pdk.GetConfig(reconnectDelayKey)
if !ok || delayStr == "" {
return defaultReconnectDelay
}
var delay int
if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 {
return defaultReconnectDelay
}
return int32(delay)
}
// shouldLogPrices reads the logPrices setting from config
func shouldLogPrices() bool {
logStr, ok := pdk.GetConfig(logPricesKey)
if !ok || logStr == "" {
return false
}
return logStr == "true"
}
// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers
@@ -164,14 +203,16 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
// Calculate 24h change percentage
change := calculatePercentChange(ticker.Open24h, ticker.Price)
// Log ticker information
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
// Log ticker information (only if enabled in config)
if shouldLogPrices() {
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
}
return nil
}
@@ -196,10 +237,11 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
// Only attempt reconnect for our connection
if input.ConnectionID == connectionID {
pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...")
delay := getReconnectDelay()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay))
// Schedule a one-time reconnection attempt
_, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
}
@@ -218,20 +260,16 @@ func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH"
}
tickers := parseTickerSymbols(tickerConfig)
symbols := getSymbols()
// Try to connect and subscribe
err := connectAndSubscribe(tickers)
err := connectAndSubscribe(symbols)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err))
delay := getReconnectDelay() * 2 // Double delay on failure
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay))
// Schedule another attempt
_, err := host.SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
}
@@ -4,6 +4,61 @@
"version": "1.0.0",
"description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
"config": {
"schema": {
"type": "object",
"properties": {
"symbols": {
"type": "array",
"title": "Trading Pairs",
"description": "Cryptocurrency trading pairs to track (default: BTC-USD)",
"items": {
"type": "string",
"title": "Trading Pair",
"pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$",
"description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)"
},
"default": ["BTC-USD"]
},
"reconnectDelay": {
"type": "integer",
"title": "Reconnect Delay",
"description": "Delay in seconds before attempting to reconnect after connection loss",
"default": 5,
"minimum": 1,
"maximum": 60
},
"logPrices": {
"type": "boolean",
"title": "Log Prices",
"description": "Whether to log price updates to the server log",
"default": false
}
}
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/symbols"
},
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/reconnectDelay"
},
{
"type": "Control",
"scope": "#/properties/logPrices"
}
]
}
]
}
},
"permissions": {
"config": {
"reason": "To read ticker symbols configuration"