From d21c7cb317f8f6cbb705fde5da5fd118538bd6c5 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 30 Mar 2026 00:04:10 +0200 Subject: [PATCH] feat(frontend): mobile UX improvements - Swipe-to-delete on list items with collapse animation (desktop keeps hover delete) - Sticky bottom Add Item input on mobile for thumb-reachable access - Redesigned burger menu as bottom sheet with portaled rendering - Inline language switcher (pill-style button group, no nested dropdown) - Eliminated 300ms tap delay via touch-action: manipulation - Smart autocomplete dropdown positioning (flips above when keyboard is open) - Fixed edit input overflow on small screens with min-w-0 --- .../src/components/AutocompleteDropdown.tsx | 26 +- frontend/src/components/BurgerMenu.tsx | 136 ++++++ frontend/src/components/HomePage.tsx | 8 +- frontend/src/components/LanguageSwitcher.tsx | 85 ++-- frontend/src/components/ListItem.tsx | 318 +++++++------ frontend/src/components/ListPage.tsx | 20 +- frontend/src/components/ThemeToggle.tsx | 103 ++++- .../components/__tests__/ListItem.test.tsx | 10 +- frontend/src/hooks/useSwipe.ts | 51 +++ frontend/src/index.css | 5 + frontend/src/translations/de.json | 7 +- frontend/src/translations/en.json | 7 +- frontend/tailwind.config.js | 12 +- .../dashboards/shoppimo-overview.json | 418 ++++++++++++++++++ 14 files changed, 981 insertions(+), 225 deletions(-) create mode 100644 frontend/src/components/BurgerMenu.tsx create mode 100644 frontend/src/hooks/useSwipe.ts create mode 100644 monitoring/grafana/provisioning/dashboards/shoppimo-overview.json diff --git a/frontend/src/components/AutocompleteDropdown.tsx b/frontend/src/components/AutocompleteDropdown.tsx index f181261..bbea036 100644 --- a/frontend/src/components/AutocompleteDropdown.tsx +++ b/frontend/src/components/AutocompleteDropdown.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; interface Props { suggestions: string[]; @@ -16,6 +16,17 @@ const AutocompleteDropdown: React.FC = ({ highlightPrefix, }) => { const [activeIndex, setActiveIndex] = useState(-1); + const [openAbove, setOpenAbove] = useState(false); + const listRef = useRef(null); + + // Determine if dropdown should open above or below + useEffect(() => { + if (!visible || !listRef.current) return; + const rect = listRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.top; + // If less than 200px below (keyboard likely open), flip above + setOpenAbove(spaceBelow < 200); + }, [visible, suggestions]); if (!visible) return null; if (suggestions.length === 0) return null; @@ -47,17 +58,17 @@ const AutocompleteDropdown: React.FC = ({ const renderHighlightedText = (text: string, prefix: string) => { if (!prefix) return <>{text}; - + const lowerText = text.toLowerCase(); const lowerPrefix = prefix.toLowerCase(); const index = lowerText.indexOf(lowerPrefix); - + if (index === -1) return <>{text}; - + const before = text.substring(0, index); const match = text.substring(index, index + prefix.length); const after = text.substring(index + prefix.length); - + return ( <> {text} @@ -72,11 +83,14 @@ const AutocompleteDropdown: React.FC = ({ return (
    {displaySuggestions.map((suggestion, index) => (
  • { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const sheetRef = useRef(null); + const { t } = useI18n(); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + const handleClick = (e: MouseEvent) => { + const target = e.target as Node; + if (!menuRef.current?.contains(target) && !sheetRef.current?.contains(target)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen]); + + // Lock body scroll on mobile when open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [isOpen]); + + const settingsContent = ( + <> + {/* Theme section */} +
    + + +
    + + {/* Divider */} +
    + + {/* Language section */} +
    + + +
    + + ); + + return ( +
    + + + {/* Desktop dropdown */} + {isOpen && ( +
    + {settingsContent} +
    + )} + + {/* Mobile bottom sheet — portaled to escape stacking context */} + {isOpen && createPortal( + <> +
    setIsOpen(false)} + aria-hidden="true" + /> +
    + {/* Drag handle */} +
    +
    +
    + + {/* Title */} +
    +

    + {t('settings.title', {}, 'Settings')} +

    +
    + + {/* Settings */} +
    + {settingsContent} +
    +
    + , + document.body + )} +
    + ); +}; + +export default BurgerMenu; diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index 2fea04e..8e90b03 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -2,8 +2,7 @@ import { useNavigate } from 'react-router-dom'; import { useState } from 'react'; import { useList } from '../context/ListContext'; import { useI18n } from '../context/I18nContext'; -import LanguageSwitcher from './LanguageSwitcher'; -import ThemeToggle from './ThemeToggle'; +import BurgerMenu from './BurgerMenu'; import RecentListsSection from './RecentListsSection'; const HomePage = () => { @@ -23,9 +22,8 @@ const HomePage = () => { return (
    -
    - - +
    +

    diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index fab0e27..f135203 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,62 +1,43 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useI18n } from '../context/I18nContext'; +const GlobeIcon = () => ( + +); + const LanguageSwitcher: React.FC = () => { const { language, setLanguage, availableLanguages } = useI18n(); - const [isOpen, setIsOpen] = useState(false); - - const currentLanguage = availableLanguages.find(lang => lang.code === language); return ( -
    - - - {isOpen && ( - <> - {/* Backdrop */} -
    setIsOpen(false)} - /> - - {/* Dropdown */} -
    -
    - {availableLanguages.map((lang) => ( - - ))} -
    -
    - - )} +
    + {availableLanguages.map((lang) => { + const isActive = language === lang.code; + return ( + + ); + })}
    ); }; -export default LanguageSwitcher; \ No newline at end of file +export default LanguageSwitcher; diff --git a/frontend/src/components/ListItem.tsx b/frontend/src/components/ListItem.tsx index a34d309..26d37f6 100644 --- a/frontend/src/components/ListItem.tsx +++ b/frontend/src/components/ListItem.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { ListItem as ListItemType } from '../types'; import { useI18n } from '../context/I18nContext'; @@ -9,48 +9,102 @@ interface ListItemProps { onToggle: (id: string) => void; } +const SWIPE_THRESHOLD = 80; +const DELETE_THRESHOLD = 160; + const ListItem = ({ item, onUpdate, onDelete, onToggle }: ListItemProps) => { - console.log('ListItem render:', { itemId: item.id, itemText: item.text, itemCompleted: item.completed }); - const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(item.text || ''); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isUpdating, setIsUpdating] = useState(false); + const [swipeX, setSwipeX] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); const { t } = useI18n(); - // Check if this is a temporary item (still being created) const isTemporary = item.id.startsWith('temp-'); + const startX = useRef(0); + const startY = useRef(0); + const isSwiping = useRef(false); + const rowRef = useRef(null); - // Sync editText with item.text when item updates (e.g., from WebSocket) useEffect(() => { if (!isEditing) { setEditText(item.text || ''); } }, [item.text, isEditing]); + // Swipe handlers + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (isEditing || isTemporary) return; + startX.current = e.touches[0].clientX; + startY.current = e.touches[0].clientY; + isSwiping.current = false; + }, [isEditing, isTemporary]); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (isEditing || isTemporary) return; + const dx = e.touches[0].clientX - startX.current; + const dy = e.touches[0].clientY - startY.current; + + if (!isSwiping.current && Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy)) { + isSwiping.current = true; + } + if (isSwiping.current) { + // Only allow swiping left (negative) + setSwipeX(Math.min(0, dx)); + } + }, [isEditing, isTemporary]); + + const handleTouchEnd = useCallback(() => { + if (!isSwiping.current) return; + if (swipeX < -DELETE_THRESHOLD) { + // Full swipe — delete + setIsDeleting(true); + setSwipeX(-9999); + setTimeout(() => onDelete(item.id), 200); + } else if (swipeX < -SWIPE_THRESHOLD) { + // Partial swipe — reveal delete button + setSwipeX(-SWIPE_THRESHOLD); + } else { + setSwipeX(0); + } + isSwiping.current = false; + }, [swipeX, item.id, onDelete]); + + // Reset swipe when tapping elsewhere + useEffect(() => { + if (swipeX !== 0) { + const handler = (e: TouchEvent | MouseEvent) => { + if (rowRef.current && !rowRef.current.contains(e.target as Node)) { + setSwipeX(0); + } + }; + document.addEventListener('touchstart', handler); + document.addEventListener('mousedown', handler); + return () => { + document.removeEventListener('touchstart', handler); + document.removeEventListener('mousedown', handler); + }; + } + }, [swipeX]); + const handleEdit = () => { - if (item.completed || isTemporary) return; // Don't allow editing completed or temporary items + if (item.completed || isTemporary) return; setIsEditing(true); setEditText(item.text || ''); }; const handleSave = async () => { const trimmedText = editText.trim(); - - // Validation: text cannot be empty if (!trimmedText) { setEditText(item.text || ''); setIsEditing(false); return; } - - // Only update if text actually changed if (trimmedText !== (item.text || '')) { setIsUpdating(true); try { await onUpdate(item.id, trimmedText); - } catch (error) { - // Revert on error + } catch { setEditText(item.text || ''); } finally { setIsUpdating(false); @@ -65,11 +119,8 @@ const ListItem = ({ item, onUpdate, onDelete, onToggle }: ListItemProps) => { }; const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSave(); - } else if (e.key === 'Escape') { - handleCancel(); - } + if (e.key === 'Enter') handleSave(); + else if (e.key === 'Escape') handleCancel(); }; const handleToggle = async () => { @@ -81,128 +132,143 @@ const ListItem = ({ item, onUpdate, onDelete, onToggle }: ListItemProps) => { }; const handleDeleteClick = () => { - if (isTemporary) return; // Don't allow deleting temporary items - setShowDeleteConfirm(true); + if (isTemporary) return; + setIsDeleting(true); + setSwipeX(-9999); + setTimeout(() => onDelete(item.id), 200); }; - const handleDeleteConfirm = async () => { - try { - await onDelete(item.id); - setShowDeleteConfirm(false); - } catch (error) { - console.error('Failed to delete item:', error); - setShowDeleteConfirm(false); - } - }; - - const handleDeleteCancel = () => { - setShowDeleteConfirm(false); - }; + // Desktop fallback delete with confirmation + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); return ( -
    - {/* Checkbox */} - - - {/* Item text */} -
    - {isEditing ? ( -
    - setEditText(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyPress} - className="flex-1 px-3 py-2 md:px-2 md:py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-base md:text-sm" - autoFocus - disabled={isUpdating} - /> - {isUpdating && ( -
    - )} -
    - ) : ( - - {item.text || ''} - - )} + {/* Delete background revealed on swipe */} +
    { if (swipeX <= -SWIPE_THRESHOLD) handleDeleteClick(); }} + aria-label={t('messages.deleteItem')} + > + + + + {t('buttons.delete')}
    - {/* Delete button with confirmation */} -
    - {showDeleteConfirm ? ( -
    - + {item.text || ''} + + )} +
    + + {/* Desktop delete button (hidden on touch via md:opacity) */} +
    + {showDeleteConfirm ? ( +
    + + +
    + ) : ( -
    - ) : ( - - )} + )} +
    +
    ); }; -export default ListItem; \ No newline at end of file +export default ListItem; diff --git a/frontend/src/components/ListPage.tsx b/frontend/src/components/ListPage.tsx index d5dd16c..9b5627b 100644 --- a/frontend/src/components/ListPage.tsx +++ b/frontend/src/components/ListPage.tsx @@ -10,8 +10,7 @@ import NotificationBell from './NotificationBell'; import ClearCompletedButton from './ClearCompletedButton'; import SyncStatusIndicator from './SyncStatusIndicator'; import ConflictNotification from './ConflictNotification'; -import LanguageSwitcher from './LanguageSwitcher'; -import ThemeToggle from './ThemeToggle'; +import BurgerMenu from './BurgerMenu'; import ExpirationIndicator from './ExpirationIndicator'; const ListPage = () => { @@ -159,9 +158,8 @@ const ListPage = () => {
    )} -
    - - +
    +
    {
    - + {/* AddItemForm: visible in header on desktop, hidden on mobile (shown at bottom instead) */} +
    + +
    {/* Expiration Indicator */} {state.list?.expiresAt && ( @@ -341,6 +342,13 @@ const ListPage = () => { )}

    + {/* Spacer for mobile sticky input */} +
    +
    + + {/* Mobile sticky bottom AddItemForm */} +
    +
    ); diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index 7cff8be..814a3bc 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -1,37 +1,96 @@ -import React from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import { useTheme } from '../context/ThemeContext'; import { useI18n } from '../context/I18nContext'; +const SunIcon = () => ( + +); + +const MoonIcon = () => ( + +); + +const MonitorIcon = () => ( + +); + const ThemeToggle: React.FC = () => { const { theme, setTheme } = useTheme(); const { t } = useI18n(); + const containerRef = useRef(null); + const [pillStyle, setPillStyle] = useState<{ left: number; width: number } | null>(null); - const themeOptions: Array<{ value: 'light' | 'dark' | 'system'; label: string; testId: string }> = [ - { value: 'light', label: t('theme.light', {}, 'Light'), testId: 'theme-option-light' }, - { value: 'dark', label: t('theme.dark', {}, 'Dark'), testId: 'theme-option-dark' }, - { value: 'system', label: t('theme.system', {}, 'System'), testId: 'theme-option-system' }, + const themeOptions: Array<{ + value: 'light' | 'dark' | 'system'; + label: string; + testId: string; + icon: React.ReactNode; + }> = [ + { value: 'light', label: t('theme.light', {}, 'Light'), testId: 'theme-option-light', icon: }, + { value: 'dark', label: t('theme.dark', {}, 'Dark'), testId: 'theme-option-dark', icon: }, + { value: 'system', label: t('theme.system', {}, 'System'), testId: 'theme-option-system', icon: }, ]; + // Measure and position the sliding pill behind the active button + useEffect(() => { + if (!containerRef.current) return; + const activeBtn = containerRef.current.querySelector( + `[data-testid="theme-option-${theme}"]` + ); + if (activeBtn) { + const containerRect = containerRef.current.getBoundingClientRect(); + const btnRect = activeBtn.getBoundingClientRect(); + setPillStyle({ + left: btnRect.left - containerRect.left, + width: btnRect.width, + }); + } + }, [theme]); + return ( -
    - {themeOptions.map((option) => ( - - ))} + {/* Sliding pill indicator */} + {pillStyle && ( +
    ); }; diff --git a/frontend/src/components/__tests__/ListItem.test.tsx b/frontend/src/components/__tests__/ListItem.test.tsx index 71df571..9338d8c 100644 --- a/frontend/src/components/__tests__/ListItem.test.tsx +++ b/frontend/src/components/__tests__/ListItem.test.tsx @@ -192,8 +192,8 @@ describe('ListItem', () => { const deleteButton = screen.getByTitle('Delete item') await user.click(deleteButton) - expect(screen.getByText('Delete')).toBeInTheDocument() - expect(screen.getByText('Cancel')).toBeInTheDocument() + expect(screen.getByTitle('Confirm delete')).toBeInTheDocument() + expect(screen.getByTitle('Cancel delete')).toBeInTheDocument() }) it('calls onDelete when delete is confirmed', async () => { @@ -212,7 +212,7 @@ describe('ListItem', () => { const deleteButton = screen.getByTitle('Delete item') await user.click(deleteButton) - const confirmButton = screen.getByText('Delete') + const confirmButton = screen.getByTitle('Confirm delete') await user.click(confirmButton) expect(mockOnDelete).toHaveBeenCalledWith('1') @@ -232,10 +232,10 @@ describe('ListItem', () => { const deleteButton = screen.getByTitle('Delete item') await user.click(deleteButton) - const cancelButton = screen.getByText('Cancel') + const cancelButton = screen.getByTitle('Cancel delete') await user.click(cancelButton) - expect(screen.queryByText('Delete')).not.toBeInTheDocument() + expect(screen.queryByTitle('Confirm delete')).not.toBeInTheDocument() expect(mockOnDelete).not.toHaveBeenCalled() }) diff --git a/frontend/src/hooks/useSwipe.ts b/frontend/src/hooks/useSwipe.ts new file mode 100644 index 0000000..f64ca6f --- /dev/null +++ b/frontend/src/hooks/useSwipe.ts @@ -0,0 +1,51 @@ +import { useRef, useCallback } from 'react'; + +interface SwipeHandlers { + onTouchStart: (e: React.TouchEvent) => void; + onTouchMove: (e: React.TouchEvent) => void; + onTouchEnd: () => void; +} + +interface UseSwipeOptions { + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + threshold?: number; +} + +export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 80 }: UseSwipeOptions): SwipeHandlers { + const startX = useRef(0); + const startY = useRef(0); + const currentX = useRef(0); + const swiping = useRef(false); + + const onTouchStart = useCallback((e: React.TouchEvent) => { + startX.current = e.touches[0].clientX; + startY.current = e.touches[0].clientY; + currentX.current = startX.current; + swiping.current = false; + }, []); + + const onTouchMove = useCallback((e: React.TouchEvent) => { + currentX.current = e.touches[0].clientX; + const dx = currentX.current - startX.current; + const dy = e.touches[0].clientY - startY.current; + + // Lock to horizontal if horizontal movement dominates + if (!swiping.current && Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy)) { + swiping.current = true; + } + }, []); + + const onTouchEnd = useCallback(() => { + if (!swiping.current) return; + const dx = currentX.current - startX.current; + if (dx < -threshold && onSwipeLeft) { + onSwipeLeft(); + } else if (dx > threshold && onSwipeRight) { + onSwipeRight(); + } + swiping.current = false; + }, [onSwipeLeft, onSwipeRight, threshold]); + + return { onTouchStart, onTouchMove, onTouchEnd }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 64d812a..c365e2e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -41,6 +41,11 @@ body { background-color: var(--color-bg-secondary); } +/* Eliminate 300ms tap delay on mobile */ +a, button, input, select, textarea, [role="option"], [role="radio"], label { + touch-action: manipulation; +} + #root { max-width: 1280px; margin: 0 auto; diff --git a/frontend/src/translations/de.json b/frontend/src/translations/de.json index 16e25ff..6a9763c 100644 --- a/frontend/src/translations/de.json +++ b/frontend/src/translations/de.json @@ -30,7 +30,7 @@ "workingOffline": "Offline-Modus", "changesWillSync": "Änderungen werden bei Wiederverbindung synchronisiert", "readyToStartShopping": "Bereit zum Einkaufen?", - "addFirstItem": "Fügen Sie Ihren ersten Artikel oben hinzu und beginnen Sie mit dem Erstellen Ihrer Einkaufsliste. Sie können sie mit anderen teilen für die Zusammenarbeit in Echtzeit!", + "addFirstItem": "Fügen Sie Ihren ersten Artikel hinzu und beginnen Sie mit dem Erstellen Ihrer Einkaufsliste. Sie können sie mit anderen teilen für die Zusammenarbeit in Echtzeit!", "listCleanupNote": "Einkaufslisten werden nach Inaktivitätsphasen automatisch bereinigt, um das System reibungslos am Laufen zu halten.", "itemBeingCreated": "Artikel wird erstellt...", "markAsIncomplete": "Als unvollständig markieren", @@ -108,5 +108,10 @@ "enabled": "Benachrichtigungen aktiviert", "blocked": "Benachrichtigungen im Browser blockiert", "disable": "Benachrichtigungen deaktivieren" + }, + "settings": { + "title": "Einstellungen", + "theme": "Darstellung", + "language": "Sprache" } } diff --git a/frontend/src/translations/en.json b/frontend/src/translations/en.json index cfb7490..a7a87e3 100644 --- a/frontend/src/translations/en.json +++ b/frontend/src/translations/en.json @@ -30,7 +30,7 @@ "workingOffline": "Working offline", "changesWillSync": "Changes will sync when reconnected", "readyToStartShopping": "Ready to start shopping?", - "addFirstItem": "Add your first item above and start building your shopping list. You can share it with others for real-time collaboration!", + "addFirstItem": "Add your first item and start building your shopping list. You can share it with others for real-time collaboration!", "listCleanupNote": "Shopping lists are automatically cleaned up after periods of inactivity to keep the system running smoothly.", "itemBeingCreated": "Item is being created...", "markAsIncomplete": "Mark as incomplete", @@ -108,5 +108,10 @@ "enabled": "Notifications enabled", "blocked": "Notifications blocked in browser", "disable": "Disable notifications" + }, + "settings": { + "title": "Settings", + "theme": "Appearance", + "language": "Language" } } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 73324ed..509eafb 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -6,7 +6,17 @@ export default { ], darkMode: 'class', theme: { - extend: {}, + extend: { + keyframes: { + 'slide-up': { + '0%': { transform: 'translateY(100%)' }, + '100%': { transform: 'translateY(0)' }, + }, + }, + animation: { + 'slide-up': 'slide-up 0.25s ease-out', + }, + }, }, plugins: [], } \ No newline at end of file diff --git a/monitoring/grafana/provisioning/dashboards/shoppimo-overview.json b/monitoring/grafana/provisioning/dashboards/shoppimo-overview.json new file mode 100644 index 0000000..0f57eca --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/shoppimo-overview.json @@ -0,0 +1,418 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 100 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "Users Online", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "shoppimo_websocket_connections_total{job=\"shopping-list-backend\"}", + "legendFormat": "Connections", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null }, + { "color": "green", "value": 10 }, + { "color": "yellow", "value": 500 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "Shopping Lists", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "shoppimo_shopping_lists_total{job=\"shopping-list-backend\"}", + "legendFormat": "Lists", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "mappings": [ + { "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } }, "type": "value" } + ], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 4, "x": 12, "y": 0 }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "Backend Status", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"shopping-list-backend\"}", + "legendFormat": "Backend", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 4, "x": 16, "y": 0 }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "Uptime", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "process_uptime_seconds{job=\"shopping-list-backend\"}", + "legendFormat": "Uptime", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 15, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 5, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Users Online (over time)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "shoppimo_websocket_connections_total{job=\"shopping-list-backend\"}", + "legendFormat": "WebSocket Connections", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 15, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 6, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Shopping Lists (over time)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "shoppimo_shopping_lists_total{job=\"shopping-list-backend\"}", + "legendFormat": "Total Lists", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": false + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "id": 7, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "HTTP Request Rate", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "rate(ktor_http_server_requests_seconds_count{job=\"shopping-list-backend\"}[5m])", + "legendFormat": "{{ method }} {{ route }} ({{ status }})", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "id": 8, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "HTTP Response Time (p95)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, rate(ktor_http_server_requests_seconds_bucket{job=\"shopping-list-backend\"}[5m]))", + "legendFormat": "{{ method }} {{ route }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" } + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "id": 9, + "options": { + "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "JVM Heap Memory", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_memory_used_bytes{job=\"shopping-list-backend\", area=\"heap\"}", + "legendFormat": "Used — {{ id }}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_memory_max_bytes{job=\"shopping-list-backend\", area=\"heap\"}", + "legendFormat": "Max — {{ id }}", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": false + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "id": 10, + "options": { + "legend": { "calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "JVM Threads", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_threads_live_threads{job=\"shopping-list-backend\"}", + "legendFormat": "Live", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_threads_daemon_threads{job=\"shopping-list-backend\"}", + "legendFormat": "Daemon", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_threads_peak_threads{job=\"shopping-list-backend\"}", + "legendFormat": "Peak", + "refId": "C" + } + ] + } + ], + "schemaVersion": 39, + "tags": ["shoppimo", "ktor", "prometheus"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, + "hide": 0, + "includeAll": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Shoppimo Overview", + "uid": "shoppimo-overview", + "version": 1 +} \ No newline at end of file