Przeglądaj źródła

feat: Implement initial Minecraft plugin with WebSocket client and Flask webapp with comprehensive database models and admin interface.

Peter Oertel 5 miesięcy temu
rodzic
commit
9fd5046441

+ 1 - 0
stellarplugin/build.gradle.kts

@@ -20,6 +20,7 @@ dependencies {
     implementation("org.java-websocket:Java-WebSocket:1.5.3")
     implementation("com.squareup.okhttp3:okhttp:4.10.0")
     implementation("com.google.code.gson:gson:2.10.1")
+    implementation("org.reflections:reflections:0.10.2")
 }
 
 tasks {

+ 30 - 0
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/SocketClient.kt

@@ -12,6 +12,13 @@ class SocketClient(uri: URI, private val logger: Logger) : WebSocketClient(uri)
 
     private val messageQueue = ConcurrentLinkedQueue<String>()
     private val lock = Any()
+    
+    // Callbacks for events
+    private var configHandler: ((List<String>) -> Unit)? = null
+
+    fun setConfigHandler(handler: (List<String>) -> Unit) {
+        configHandler = handler
+    }
 
     fun shutdown() {
         shouldReconnect = false
@@ -44,6 +51,29 @@ class SocketClient(uri: URI, private val logger: Logger) : WebSocketClient(uri)
             return
         }
         logger.info("Received message: $message")
+        
+        // Basic parsing for config_update
+        // Format: 42["config_update",{"enabled_events":["Event1", "Event2"]}]
+        if (message != null && message.startsWith("42[\"config_update\"")) {
+            try {
+                val jsonPart = message.substring(message.indexOf(',') + 1, message.lastIndexOf(']'))
+                // We could use Gson here but let's do simple parsing or use the Gson instance in UniversalListener?
+                // Actually we just added Gson dependency so use it.
+                // But SocketClient doesn't have Gson instance. Reference it? Or manual parsing to avoid complexity?
+                // Let's use simple manual parsing for now as it's just one list.
+                // Or better, let UniversalListener handle parsing if we pass the raw string?
+                // No, nicer to pass the list.
+                
+                // Let's rely on string manipulation for the list of strings since it's cleaner than adding Gson property here
+                if (jsonPart.contains("enabled_events")) {
+                     val listPart = jsonPart.substringAfter("[\"").substringBefore("\"]")
+                     val events = listPart.split("\",\"")
+                     configHandler?.invoke(events)
+                }
+            } catch (e: Exception) {
+                logger.warning("Failed to parse config update: ${e.message}")
+            }
+        }
     }
     
     fun sendSocketEvent(eventName: String, jsonObject: String) {

+ 2 - 1
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/StellarPlugin.kt

@@ -23,7 +23,8 @@ class StellarPlugin : JavaPlugin() {
         }
 
         // Register Listeners
-        server.pluginManager.registerEvents(GameListener(socketClient!!), this)
+        // GameListener is superseded by UniversalListener
+        io.stellarfrontier.stellarplugin.listeners.UniversalListener(this, socketClient!!)
 
         // Register Commands
         getCommand("link")?.setExecutor(LinkCommand())

+ 124 - 0
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/listeners/UniversalListener.kt

@@ -0,0 +1,124 @@
+package io.stellarfrontier.stellarplugin.listeners
+
+import com.google.gson.Gson
+import io.stellarfrontier.stellarplugin.SocketClient
+import org.bukkit.Bukkit
+import org.bukkit.event.Event
+import org.bukkit.event.EventPriority
+import org.bukkit.event.Listener
+import org.bukkit.plugin.EventExecutor
+import org.bukkit.plugin.Plugin
+import org.reflections.Reflections
+import java.lang.reflect.Modifier
+import java.time.Instant
+
+class UniversalListener(private val plugin: Plugin, private val socket: SocketClient) : Listener {
+    private val gson = Gson()
+    private val registeredEvents = mutableSetOf<String>()
+    
+    // Configurable enabled events (allowlist)
+    // Initially empty, populated by server 'config_update'
+    private var enabledEvents = setOf<String>()
+
+    init {
+        // Listen for config updates
+        socket.setConfigHandler { events ->
+            enabledEvents = events.toSet()
+            plugin.logger.info("Updated event config: ${enabledEvents.size} events enabled")
+        }
+        
+        registerAllEvents()
+        sendMetadata()
+    }
+
+    private fun registerAllEvents() {
+        val reflections = Reflections("org.bukkit.event")
+        val eventClasses = reflections.getSubTypesOf(Event::class.java)
+
+        for (clazz in eventClasses) {
+            if (isValidEvent(clazz)) {
+                try {
+                    val executor = EventExecutor { _, event ->
+                        handleEvent(event)
+                    }
+                    
+                    // We register the event dynamically
+                    plugin.server.pluginManager.registerEvent(
+                        clazz as Class<out Event>,
+                        this,
+                        EventPriority.MONITOR,
+                        executor,
+                        plugin
+                    )
+                    registeredEvents.add(clazz.name)
+                } catch (e: Exception) {
+                    // Ignore registration failures
+                }
+            }
+        }
+        plugin.logger.info("UniversalListener registered ${registeredEvents.size} event types")
+    }
+
+    private fun isValidEvent(clazz: Class<out Event>): Boolean {
+        return !Modifier.isAbstract(clazz.modifiers) && 
+               !Modifier.isInterface(clazz.modifiers)
+    }
+
+    private fun handleEvent(event: Event) {
+        val eventName = event.javaClass.name // Full class name matches DB/Config
+        
+        // Check allowlist
+        if (!enabledEvents.contains(eventName)) return
+
+        val data = mutableMapOf<String, Any>(
+            "type" to event.javaClass.simpleName,
+            "package" to event.javaClass.name.substringBeforeLast('.'),
+            "timestamp" to Instant.now().toString(),
+            "event_data" to serializeEvent(event)
+        )
+
+        // Try to get player name if available for better logging/display
+        try {
+            val getPlayer = event.javaClass.getMethod("getPlayer")
+            val player = getPlayer.invoke(event)
+            if (player != null) {
+                // It's a player, get their name
+                val getName = player.javaClass.getMethod("getName")
+                data["player"] = getName.invoke(player)
+            }
+        } catch (ignored: Exception) {}
+
+        socket.sendSocketEvent("universal_event", gson.toJson(data))
+    }
+
+    private fun serializeEvent(event: Event): Map<String, Any?> {
+        val map = mutableMapOf<String, Any?>()
+        // Use reflection to get all getter methods
+        event.javaClass.methods.forEach { method ->
+            if (method.name.startsWith("get") && method.parameterCount == 0 && method.name != "getClass") {
+                try {
+                    val fieldName = method.name.substring(3).replaceFirstChar { it.lowercase() }
+                     // Skip complex objects that might cause recursion or are too large
+                    val value = method.invoke(event)
+                    if (isSafeToSerialize(value)) {
+                         map[fieldName] = value.toString() // Simplify to string for now to avoid accidental recursion
+                    }
+                } catch (ignored: Exception) {}
+            }
+        }
+        return map
+    }
+
+    private fun isSafeToSerialize(obj: Any?): Boolean {
+        if (obj == null) return true
+        return obj is String || obj is Number || obj is Boolean || obj is Enum<*>
+    }
+
+    public fun sendMetadata() {
+        // Group by package
+        val params = mapOf(
+            "events" to registeredEvents.toList()
+        )
+        socket.sendSocketEvent("event_metadata", gson.toJson(params))
+    }
+}

+ 98 - 1
stellarwebapp/events.py

@@ -74,7 +74,104 @@ def handle_game_event(data):
     print(f"Broadcasting event: {data}")
     emit('game_event', data, broadcast=True)
 
[email protected]('universal_event')
+def handle_universal_event(data):
+    # data = {type: 'EventName', package: 'pkg', timestamp: '...', event_data: {...}, player: 'Name'}
+    print(f"Received universal event: {data.get('type')}")
+    
+    # Save to DB
+    try:
+        event_timestamp = None
+        if data.get('timestamp'):
+            try:
+                event_timestamp = parser.parse(data.get('timestamp'))
+            except Exception as e:
+                even_timestamp = datetime.utcnow()
+
+        # Flatten event_data to string for content if no content provided
+        content = data.get('content')
+        if not content and 'event_data' in data:
+            content = json.dumps(data.get('event_data'))
+
+        # Map to existing schema
+        event = Event(
+            event_type=data.get('type'),
+            player_uuid=data.get('uuid'), # Might be missing in generic events
+            player_name=data.get('player'),
+            content=content, 
+            location=data.get('event_data', {}).get('location'), # Try to find location
+            timestamp=event_timestamp or datetime.utcnow()
+        )
+        db.session.add(event)
+        db.session.commit()
+    except Exception as e:
+        print(f"Error saving universal event: {e}")
+        db.session.rollback()
+
+    emit('universal_event', data, broadcast=True)
+
+DEFAULT_IGNORED_EVENTS = {
+    "org.bukkit.event.player.PlayerMoveEvent",
+    "org.bukkit.event.player.PlayerInputEvent",
+    "org.bukkit.event.player.PlayerStatisticIncrementEvent",
+    "org.bukkit.event.player.PlayerVelocityEvent",
+    "org.bukkit.event.vehicle.VehicleMoveEvent",
+    "org.bukkit.event.block.BlockPhysicsEvent",
+    "org.bukkit.event.world.ChunkLoadEvent",
+    "org.bukkit.event.world.ChunkUnloadEvent",
+    "com.destroystokyo.paper.event.server.ServerTickEndEvent",
+    "com.destroystokyo.paper.event.server.ServerTickStartEvent",
+    "org.bukkit.event.vehicle.VehicleUpdateEvent",
+    "org.bukkit.event.entity.EntityAirChangeEvent",
+    "org.bukkit.event.entity.CreatureSpawnEvent",
+    "org.bukkit.event.world.GenericGameEvent",
+    "org.bukkit.event.world.EntitiesLoadEvent",
+    "org.bukkit.event.world.EntitiesUnloadEvent",
+    "org.bukkit.event.entity.BatToggleSleepEvent",
+    "org.bukkit.event.entity.EntityPoseChangeEvent",
+    "org.bukkit.event.entity.EntityRemoveEvent"
+}
+
[email protected]('event_metadata')
+def handle_event_metadata(data):
+    from models import EventConfig
+    print(f"Received event metadata: {len(data.get('events', []))} events registered")
+    
+    # Sync with DB
+    received_events = data.get('events', [])
+    try:
+        # Get existing configs
+        existing = {c.event_name: c for c in EventConfig.query.all()}
+        
+        for event_name in received_events:
+            if event_name not in existing:
+                # Add new event
+                package_name = event_name.rsplit('.', 1)[0]
+                is_enabled = event_name not in DEFAULT_IGNORED_EVENTS
+                new_config = EventConfig(event_name=event_name, package_name=package_name, is_enabled=is_enabled)
+                db.session.add(new_config)
+            
+        db.session.commit()
+        
+        # Send back configuration
+        send_config_update()
+        
+    except Exception as e:
+        print(f"Error syncing event config: {e}")
+        db.session.rollback()
+
+    emit('event_metadata', data, broadcast=True)
+
+def send_config_update():
+    from models import EventConfig
+    try:
+        enabled_events = [c.event_name for c in EventConfig.query.filter_by(is_enabled=True).all()]
+        print(f"Sending config update: {len(enabled_events)} enabled events")
+        emit('config_update', {'enabled_events': enabled_events}, broadcast=True)
+    except Exception as e:
+        print(f"Error sending configs: {e}")
+
 @socketio.on("*")
 def handle_any_event(event, methods=None, data=None):
-    if event not in ['connect', 'disconnect']:
+    if event not in ['connect', 'disconnect', 'game_event', 'universal_event', 'event_metadata']:
         print(f"Server received event: {event} with data: {data}")

+ 6 - 0
stellarwebapp/models.py

@@ -36,6 +36,12 @@ class Event(db.Model):
             'inventory': json.loads(self.inventory.items) if self.inventory else None
         }
 
+class EventConfig(db.Model):
+    __tablename__ = 'event_configs'
+    event_name = db.Column(db.String(255), primary_key=True) # Full class name
+    package_name = db.Column(db.String(255), nullable=False)
+    is_enabled = db.Column(db.Boolean, default=True)
+
 class Inventory(db.Model):
     __tablename__ = 'inventories'
     id = db.Column(db.Integer, primary_key=True)

+ 45 - 0
stellarwebapp/routes/admin.py

@@ -49,4 +49,49 @@ def user_detail(user_id):
         (Ticket.user_id == user.id) | (TicketAssignment.user_id == user.id)
     ).all()
     
+    
     return render_template('admin/user_detail.html', user=user, tickets=relevant_tickets)
+
+@admin_bp.route('/settings/events', methods=['GET', 'POST'])
+@login_required
+@admin_required
+def event_settings():
+    from models import EventConfig
+    from extensions import db, socketio
+    
+    if request.method == 'POST':
+        # Update configs
+        all_configs = EventConfig.query.all()
+        for config in all_configs:
+            # Checkbox: if present, it's 'on'. keys are event_name
+            config.is_enabled = request.form.get(config.event_name) == 'on'
+            
+        db.session.commit()
+        
+        # Send update to plugin
+        enabled_events = [c.event_name for c in EventConfig.query.filter_by(is_enabled=True).all()]
+        socketio.emit('config_update', {'enabled_events': enabled_events})
+        
+        # Flash message ideally, but we'll specificy return to page
+        return render_template('admin/event_settings.html', 
+                             configs=group_configs(EventConfig.query.all()),
+                             success=True)
+
+    configs = EventConfig.query.all()
+    if not configs:
+        # Prompt user to restart/connect server if empty
+        pass
+        
+    return render_template('admin/event_settings.html', configs=group_configs(configs))
+
+def group_configs(configs):
+    # Group by package
+    grouped = {}
+    for config in configs:
+        pkg = config.package_name
+        if pkg not in grouped:
+            grouped[pkg] = []
+        grouped[pkg].append(config)
+    
+    # Sort keys
+    return dict(sorted(grouped.items()))

+ 95 - 42
stellarwebapp/templates/admin/dashboard.html

@@ -10,6 +10,7 @@
                 <li><a href="{{ url_for('admin.user_search') }}">User Search</a></li>
                 <li><a>Players</a></li>
                 <li><a>Bans</a></li>
+                <li><a href="{{ url_for('admin.event_settings') }}">Event Settings</a></li>
             </ul>
         </aside>
     </div>
@@ -21,12 +22,14 @@
 
         <div class="field is-grouped">
             <div class="control">
+                <div class="select">
+                    <select id="package-filter">
+                        <option value="ALL">All Packages</option>
+                    </select>
+                </div>
                 <div class="select">
                     <select id="event-filter">
                         <option value="ALL">All Events</option>
-                        <option value="CHAT">Chat</option>
-                        <option value="JOIN">Join/Quit</option>
-                        <option value="DEATH">Death</option>
                     </select>
                 </div>
             </div>
@@ -43,72 +46,122 @@
 <script>
     const socket = io();
     const feed = document.getElementById('event-feed');
-    const filter = document.getElementById('event-filter');
+    const packageFilter = document.getElementById('package-filter');
+    const eventFilter = document.getElementById('event-filter'); // Now dynamic
+
+    let registeredEvents = []; // List of full class names
 
     socket.on('connect', () => {
         console.log('Connected to WebSocket');
     });
 
-    socket.on('game_event', (data) => {
+    socket.on('event_metadata', (data) => {
+        console.log("Received metadata", data);
+        registeredEvents = data.events;
+        populateFilters();
+    });
+
+    socket.on('universal_event', (data) => {
+        // data = {type: 'PlayerJoinEvent', package: 'org.bukkit...', event_data: {...}, ...}
+
         const div = document.createElement('div');
         div.className = 'notification is-small is-dark';
         div.style.marginBottom = '0.5rem';
         div.style.padding = '0.5rem';
-        div.dataset.type = data.type; // Store type for filtering
+        div.dataset.package = data.package;
+        div.dataset.type = data.type;
 
-        // Check if we should show this event based on current filter
-        if (!shouldShowEvent(data.type, filter.value)) {
+        // Filtering
+        if (!shouldShowEvent(data.package, data.type)) {
             div.style.display = 'none';
         }
 
-        let content = '';
         const time = new Date(data.timestamp).toLocaleTimeString();
+        let content = `[${time}] <strong>${data.type}</strong> `;
 
-        switch (data.type) {
-            case 'CHAT':
-                content = `[${time}] <strong>${data.player}</strong>: ${data.content}`;
-                break;
-            case 'JOIN':
-                content = `[${time}] <span class="has-text-success">+</span> <strong>${data.player}</strong> joined the game.`;
-                break;
-            case 'QUIT':
-                content = `[${time}] <span class="has-text-danger">-</span> <strong>${data.player}</strong> left the game.`;
-                break;
-            case 'DEATH':
-                content = `[${time}] <span class="has-text-danger">☠</span> ${data.content}`;
-                break;
-            default:
-                content = `[${time}] [${data.type}] ${data.content}`;
+        if (data.player) {
+            content += `(${data.player}) `;
+        }
+
+        // Format event_data
+        if (data.event_data) {
+            content += `<br/><span style="font-size: 0.8em; color: #ccc;">${JSON.stringify(data.event_data).substring(0, 200)}</span>`;
         }
 
         div.innerHTML = content;
         feed.appendChild(div);
 
-        // Only scroll if visible or if we want to force scroll (usually good to scroll if new event arrived)
         if (div.style.display !== 'none') {
             feed.scrollTop = feed.scrollHeight;
         }
     });
 
-    filter.addEventListener('change', () => {
-        const selectedFilter = filter.value;
-        const events = feed.children;
-
-        for (let event of events) {
-            const type = event.dataset.type;
-            if (shouldShowEvent(type, selectedFilter)) {
-                event.style.display = '';
-            } else {
-                event.style.display = 'none';
-            }
-        }
+    // Listen for filter changes
+    packageFilter.addEventListener('change', () => {
+        populateEventFilter(); // Update event dropdown based on package
+        applyFilters();
     });
 
-    function shouldShowEvent(eventType, filterValue) {
-        if (filterValue === 'ALL') return true;
-        if (filterValue === eventType) return true;
-        if (filterValue === 'JOIN' && (eventType === 'JOIN' || eventType === 'QUIT')) return true;
-        return false;
+    eventFilter.addEventListener('change', applyFilters);
+
+    function populateFilters() {
+        // Get unique packages
+        const packages = new Set(registeredEvents.map(e => e.substring(0, e.lastIndexOf('.'))));
+
+        packageFilter.innerHTML = '<option value="ALL">All Packages</option>';
+        [...packages].sort().forEach(pkg => {
+            const opt = document.createElement('option');
+            opt.value = pkg;
+            opt.textContent = pkg.replace('org.bukkit.event.', '...'); // Shorten common prefix
+            packageFilter.appendChild(opt);
+        });
+
+        populateEventFilter();
+    }
+
+    function populateEventFilter() {
+        const selectedPackage = packageFilter.value;
+        const currentSelection = eventFilter.value;
+
+        eventFilter.innerHTML = '<option value="ALL">All Events</option>';
+
+        registeredEvents
+            .filter(e => selectedPackage === 'ALL' || e.startsWith(selectedPackage))
+            .map(e => e.substring(e.lastIndexOf('.') + 1)) // Get simple name
+            .sort()
+            .forEach(simpleName => {
+                const opt = document.createElement('option');
+                opt.value = simpleName;
+                opt.textContent = simpleName;
+                eventFilter.appendChild(opt);
+            });
+
+        // restore selection if valid
+        // simplistic restoration, might reset if package changed effectively
+    }
+
+    function applyFilters() {
+        const pFilter = packageFilter.value;
+        const eFilter = eventFilter.value;
+
+        for (let div of feed.children) {
+            const p = div.dataset.package;
+            const t = div.dataset.type;
+
+            let show = true;
+            if (pFilter !== 'ALL' && p !== pFilter) show = false;
+            if (eFilter !== 'ALL' && t !== eFilter) show = false;
+
+            div.style.display = show ? '' : 'none';
+        }
+    }
+
+    function shouldShowEvent(pkg, type) {
+        const pFilter = packageFilter.value;
+        const eFilter = eventFilter.value;
+        if (pFilter !== 'ALL' && pkg !== pFilter) return false;
+        if (eFilter !== 'ALL' && type !== eFilter) return false;
+        return true;
     }
 
     function clearFeed() {

+ 112 - 0
stellarwebapp/templates/admin/event_settings.html

@@ -0,0 +1,112 @@
+{% extends "base.html" %}
+
+{% block content %}
+<div class="columns">
+    <div class="column is-3">
+        <aside class="menu">
+            <p class="menu-label">Administration</p>
+            <ul class="menu-list">
+                <li><a href="{{ url_for('admin.dashboard') }}">Dashboard</a></li>
+                <li><a href="{{ url_for('admin.user_search') }}">User Search</a></li>
+                <li><a>Players</a></li>
+                <li><a>Bans</a></li>
+                <li><a href="{{ url_for('admin.event_settings') }}" class="is-active">Event Settings</a></li>
+            </ul>
+        </aside>
+    </div>
+    <div class="column">
+        <h1 class="title">Event Logging Settings</h1>
+        {% if success %}
+        <div class="notification is-success">
+            Settings saved successfully! Plugin updated.
+        </div>
+        {% endif %}
+
+        {% if not configs %}
+        <div class="notification is-warning">
+            No events found. Please start the Minecraft server and wait for it to connect to populate this list.
+        </div>
+        {% else %}
+        <form method="POST">
+            <div class="tabs is-boxed is-small" style="flex-wrap: wrap;">
+                <ul id="package-tabs">
+                    {% for package in configs.keys() %}
+                    <li class="{% if loop.first %}is-active{% endif %}" data-tab="{{ package }}">
+                        <a>{{ package|replace('org.bukkit.event.', '') }}</a>
+                    </li>
+                    {% endfor %}
+                </ul>
+            </div>
+
+            <div id="tab-content">
+                {% for package, events in configs.items() %}
+                <div class="content-tab {% if not loop.first %}is-hidden{% endif %}" data-content="{{ package }}">
+                    <table class="table is-fullwidth is-striped">
+                        <thead>
+                            <tr>
+                                <th>Event Name</th>
+                                <th>Logging Enabled</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for config in events %}
+                            <tr>
+                                <td>
+                                    <strong>{{ config.event_name.split('.')[-1] }}</strong><br>
+                                    <small class="has-text-grey">{{ config.event_name }}</small>
+                                </td>
+                                <td>
+                                    <label class="switch">
+                                        <input type="checkbox" name="{{ config.event_name }}" {% if config.is_enabled
+                                            %}checked{% endif %}>
+                                        <span class="slider round"></span>
+                                        <!-- You might need CSS for this or use standard checkbox -->
+                                        Enable
+                                    </label>
+                                </td>
+                            </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+                {% endfor %}
+            </div>
+
+            <div class="field is-grouped is-grouped-right"
+                style="position: sticky; bottom: 20px; background: #222; padding: 10px; border-radius: 5px;">
+                <div class="control">
+                    <button class="button is-primary">Save Changes</button>
+                </div>
+            </div>
+        </form>
+        {% endif %}
+    </div>
+</div>
+
+<style>
+    /* Basic Switch CSS if not using a library */
+    /* Adjust as needed or use standard checkbox */
+</style>
+
+<script>
+    document.addEventListener('DOMContentLoaded', () => {
+        const tabs = document.querySelectorAll('#package-tabs li');
+        const contents = document.querySelectorAll('.content-tab');
+
+        tabs.forEach(tab => {
+            tab.addEventListener('click', () => {
+                // Remove active from all tabs
+                tabs.forEach(t => t.classList.remove('is-active'));
+                // Add active to clicked
+                tab.classList.add('is-active');
+
+                // Hide all contents
+                contents.forEach(c => c.classList.add('is-hidden'));
+                // Show target content
+                const target = tab.dataset.tab;
+                document.querySelector(`.content-tab[data-content="${target}"]`).classList.remove('is-hidden');
+            });
+        });
+    });
+</script>
+{% endblock %}

+ 6 - 1
stellarwebapp/templates/base.html

@@ -113,7 +113,8 @@
         }
 
         .table tr:hover,
-        .table.is-hoverable tbody tr:not(.is-selected):hover {
+        .table.is-hoverable tbody tr:not(.is-selected):hover,
+        .table.is-striped tbody tr:not(.is-selected):nth-child(2n):hover {
             background-color: #2a2a2a;
         }
 
@@ -122,6 +123,10 @@
             border-color: #333;
         }
 
+        .table.is-striped tbody tr:not(.is-selected):nth-child(2n) {
+            background-color: #212121;
+        }
+
         .modal-card-head,
         .modal-card-body,
         .modal-card-foot {