From 7ff00efde74565eca3802d2bdb7893f3ba82122f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 25 May 2026 21:23:11 +0000 Subject: [PATCH] 1 --- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 7 + frontend/src/pages/control-center.tsx | 1579 ++++++++++++++++++++++++ frontend/src/pages/dashboard.tsx | 22 + frontend/src/pages/index.tsx | 395 +++--- 6 files changed, 1861 insertions(+), 148 deletions(-) create mode 100644 frontend/src/pages/control-center.tsx diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index be60e92..b6b1581 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/control-center', + label: 'Control Center', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiTune' in icon ? icon['mdiTune' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + }, { href: '/users/users-list', diff --git a/frontend/src/pages/control-center.tsx b/frontend/src/pages/control-center.tsx new file mode 100644 index 0000000..13564b1 --- /dev/null +++ b/frontend/src/pages/control-center.tsx @@ -0,0 +1,1579 @@ +import * as icon from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React from 'react'; +import type { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseDivider from '../components/BaseDivider'; +import CardBox from '../components/CardBox'; +import LoadingSpinner from '../components/LoadingSpinner'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; +import FormField from '../components/FormField'; + +type NoticeTone = 'success' | 'danger' | 'info' | 'warning'; + +type Notice = { + tone: NoticeTone; + message: string; +}; + +type CountResponse = { + count: number; +}; + +type ListResponse = { + rows: T[]; + count: number; +}; + +type SummaryCounts = { + wallets: number | null; + pools: number | null; + configs: number | null; + devices: number | null; + sessions: number | null; + payouts: number | null; +}; + +type WalletRecord = { + id: string; + label: string | null; + chain: string | null; + currency_symbol: string | null; + address: string | null; + is_default: boolean; + verified_at: string | null; +}; + +type PoolRecord = { + id: string; + pool_name: string | null; + protocol: string | null; + endpoint_url: string | null; + coin: string | null; + is_active: boolean; + priority: number | string | null; +}; + +type ConfigRecord = { + id: string; + mining_mode: string | null; + threads: number | string | null; + cpu_usage_limit_percent: number | string | null; + auto_start: boolean; + run_in_background: boolean; + throttle_on_battery: boolean; + battery_threshold_percent: number | string | null; + wallet?: Pick | null; + pool?: Pick | null; +}; + +type DeviceRecord = { + id: string; + device_name: string | null; +}; + +type SessionRecord = { + id: string; + status: string | null; + avg_hashrate_hs: number | string | null; + max_hashrate_hs: number | string | null; + cpu_avg_percent: number | string | null; + started_at: string | null; + stopped_at: string | null; + device?: DeviceRecord | null; + mining_config?: Pick | null; +}; + +type PayoutRecord = { + id: string; + status: string | null; + amount: number | string | null; + tx_hash: string | null; + paid_at: string | null; + wallet?: Pick | null; + device?: DeviceRecord | null; +}; + +type WalletFormState = { + label: string; + chain: string; + currency_symbol: string; + address: string; + is_default: boolean; +}; + +type PoolFormState = { + pool_name: string; + protocol: string; + endpoint_url: string; + coin: string; + username_template: string; + password_template: string; + is_active: boolean; + priority: string; +}; + +type ConfigFormState = { + wallet: string; + pool: string; + mining_mode: string; + threads: string; + cpu_usage_limit_percent: string; + auto_start: boolean; + run_in_background: boolean; + throttle_on_battery: boolean; + battery_threshold_percent: string; +}; + +type FormErrors = Partial>; + +type MetricCardProps = { + iconPath: string; + label: string; + value: number | string | null; + helper: string; + accentClass: string; +}; + +type ToggleFieldProps = { + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; +}; + +const walletChainOptions = [ + { value: 'bitcoin', label: 'Bitcoin' }, + { value: 'ethereum', label: 'Ethereum' }, + { value: 'monero', label: 'Monero' }, + { value: 'solana', label: 'Solana' }, + { value: 'tron', label: 'Tron' }, + { value: 'polygon', label: 'Polygon' }, + { value: 'litecoin', label: 'Litecoin' }, + { value: 'other', label: 'Other' }, +]; + +const poolProtocolOptions = [ + { value: 'stratum', label: 'Stratum' }, + { value: 'http', label: 'HTTP' }, + { value: 'https', label: 'HTTPS' }, + { value: 'ws', label: 'WebSocket' }, + { value: 'wss', label: 'Secure WebSocket' }, +]; + +const poolCoinOptions = [ + { value: 'xmr', label: 'XMR' }, + { value: 'btc', label: 'BTC' }, + { value: 'eth', label: 'ETH' }, + { value: 'etc', label: 'ETC' }, + { value: 'ltc', label: 'LTC' }, + { value: 'other', label: 'Other' }, +]; + +const miningModeOptions = [ + { value: 'cpu', label: 'CPU' }, + { value: 'gpu', label: 'GPU' }, + { value: 'wasm', label: 'WASM' }, + { value: 'webworker', label: 'Web Worker' }, + { value: 'auto', label: 'Auto' }, +]; + +const initialWalletForm: WalletFormState = { + label: 'Primary payout wallet', + chain: 'monero', + currency_symbol: 'XMR', + address: '', + is_default: true, +}; + +const initialPoolForm: PoolFormState = { + pool_name: 'Primary browser pool', + protocol: 'stratum', + endpoint_url: '', + coin: 'xmr', + username_template: 'wallet.worker', + password_template: 'x', + is_active: true, + priority: '1', +}; + +const initialConfigForm: ConfigFormState = { + wallet: '', + pool: '', + mining_mode: 'auto', + threads: '2', + cpu_usage_limit_percent: '55', + auto_start: false, + run_in_background: true, + throttle_on_battery: true, + battery_threshold_percent: '25', +}; + +const getMdi = (name: string, fallback = icon.mdiTable) => { + return (name in icon ? icon[name as keyof typeof icon] : fallback) as string; +}; + +const FieldError = ({ message }: { message?: string }) => { + if (!message) { + return null; + } + + return

{message}

; +}; + +const MetricCard = ({ iconPath, label, value, helper, accentClass }: MetricCardProps) => { + return ( + +
+
+

{label}

+

{value ?? '—'}

+

{helper}

+
+
+ + + +
+
+
+ ); +}; + +const ToggleField = ({ label, description, checked, onChange }: ToggleFieldProps) => { + return ( + + ); +}; + +const formatCount = (value: number | null) => { + if (value === null || value === undefined) { + return '—'; + } + + return new Intl.NumberFormat('en-US').format(value); +}; + +const formatNumber = (value: number | string | null, suffix = '') => { + const numericValue = Number(value ?? 0); + + if (Number.isNaN(numericValue)) { + return `0${suffix}`; + } + + return `${new Intl.NumberFormat('en-US', { + maximumFractionDigits: numericValue >= 100 ? 0 : 2, + }).format(numericValue)}${suffix}`; +}; + +const formatDate = (value: string | null) => { + if (!value) { + return 'Not scheduled'; + } + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)); +}; + +const truncateMiddle = (value: string | null, head = 6, tail = 4) => { + if (!value) { + return 'Not connected'; + } + + if (value.length <= head + tail) { + return value; + } + + return `${value.slice(0, head)}…${value.slice(-tail)}`; +}; + +const statusClasses = (status: string | null) => { + switch (status) { + case 'running': + case 'paid': + return 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300'; + case 'starting': + case 'queued': + case 'pending': + return 'border-cyan-400/20 bg-cyan-400/10 text-cyan-300'; + case 'paused': + return 'border-amber-400/20 bg-amber-400/10 text-amber-300'; + case 'error': + case 'failed': + return 'border-rose-400/20 bg-rose-400/10 text-rose-300'; + default: + return 'border-white/10 bg-white/[0.05] text-slate-300'; + } +}; + +const getErrorMessage = (error: unknown) => { + if (axios.isAxiosError(error)) { + return ( + error.response?.data?.message || + error.response?.data?.error || + error.message || + 'The request could not be completed.' + ); + } + + if (error instanceof Error) { + return error.message; + } + + return 'The request could not be completed.'; +}; + +const ControlCenter = () => { + const { currentUser } = useAppSelector((state) => state.auth); + + const permissions = React.useMemo( + () => ({ + canReadWallets: hasPermission(currentUser, 'READ_WALLETS'), + canCreateWallets: hasPermission(currentUser, 'CREATE_WALLETS'), + canReadPools: hasPermission(currentUser, 'READ_MINING_POOLS'), + canCreatePools: hasPermission(currentUser, 'CREATE_MINING_POOLS'), + canReadConfigs: hasPermission(currentUser, 'READ_MINING_CONFIGS'), + canCreateConfigs: hasPermission(currentUser, 'CREATE_MINING_CONFIGS'), + canReadDevices: hasPermission(currentUser, 'READ_DEVICES'), + canReadSessions: hasPermission(currentUser, 'READ_MINING_SESSIONS'), + canReadPayouts: hasPermission(currentUser, 'READ_REWARD_PAYOUTS'), + }), + [currentUser], + ); + + const [summary, setSummary] = React.useState({ + wallets: null, + pools: null, + configs: null, + devices: null, + sessions: null, + payouts: null, + }); + + const [wallets, setWallets] = React.useState([]); + const [pools, setPools] = React.useState([]); + const [configs, setConfigs] = React.useState([]); + const [sessions, setSessions] = React.useState([]); + const [payouts, setPayouts] = React.useState([]); + + const [walletForm, setWalletForm] = React.useState(initialWalletForm); + const [poolForm, setPoolForm] = React.useState(initialPoolForm); + const [configForm, setConfigForm] = React.useState(initialConfigForm); + + const [walletErrors, setWalletErrors] = React.useState>({}); + const [poolErrors, setPoolErrors] = React.useState>({}); + const [configErrors, setConfigErrors] = React.useState>({}); + + const [walletNotice, setWalletNotice] = React.useState(null); + const [poolNotice, setPoolNotice] = React.useState(null); + const [configNotice, setConfigNotice] = React.useState(null); + const [pageNotice, setPageNotice] = React.useState(null); + + const [isLoading, setIsLoading] = React.useState(true); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [isSubmittingWallet, setIsSubmittingWallet] = React.useState(false); + const [isSubmittingPool, setIsSubmittingPool] = React.useState(false); + const [isSubmittingConfig, setIsSubmittingConfig] = React.useState(false); + + const walletOptions = wallets.slice(0, 25); + const poolOptions = pools.slice(0, 25); + const recentWallets = wallets.slice(0, 4); + const recentConfigs = configs.slice(0, 4); + const recentSessions = sessions.slice(0, 4); + const recentPayouts = payouts.slice(0, 4); + + const aggregateHashrate = React.useMemo(() => { + return recentSessions.reduce((total, session) => total + Number(session.avg_hashrate_hs ?? 0), 0); + }, [recentSessions]); + + const aggregatePayout = React.useMemo(() => { + return recentPayouts.reduce((total, payout) => total + Number(payout.amount ?? 0), 0); + }, [recentPayouts]); + + const readinessSteps = React.useMemo( + () => [ + { + title: 'Payout wallet connected', + description: 'Rewards can be routed to a default wallet profile.', + done: (summary.wallets ?? 0) > 0, + }, + { + title: 'Pool endpoint configured', + description: 'The control plane knows which endpoint a browser client should use.', + done: (summary.pools ?? 0) > 0, + }, + { + title: 'Runtime profile saved', + description: 'CPU caps, battery throttling, and background behavior are defined.', + done: (summary.configs ?? 0) > 0, + }, + { + title: 'Operations telemetry visible', + description: 'Sessions and payout records are flowing into the admin view.', + done: (summary.sessions ?? 0) > 0 || (summary.payouts ?? 0) > 0, + }, + ], + [summary], + ); + + const completedSteps = readinessSteps.filter((step) => step.done).length; + + const loadControlData = React.useCallback( + async (mode: 'initial' | 'refresh' = 'initial') => { + if (!currentUser) { + return; + } + + if (mode === 'initial') { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + + const fetchCount = async (enabled: boolean, path: string) => { + if (!enabled) { + return null; + } + + const response = await axios.get(path); + return response.data.count; + }; + + const fetchRows = async (enabled: boolean, path: string, limit: number) => { + if (!enabled) { + return [] as T[]; + } + + const response = await axios.get>(path, { + params: { + page: 0, + limit, + }, + }); + + return Array.isArray(response.data.rows) ? response.data.rows : []; + }; + + try { + setPageNotice(null); + + const [ + walletsCount, + poolsCount, + configsCount, + devicesCount, + sessionsCount, + payoutsCount, + walletRows, + poolRows, + configRows, + sessionRows, + payoutRows, + ] = await Promise.all([ + fetchCount(permissions.canReadWallets, '/wallets/count'), + fetchCount(permissions.canReadPools, '/mining_pools/count'), + fetchCount(permissions.canReadConfigs, '/mining_configs/count'), + fetchCount(permissions.canReadDevices, '/devices/count'), + fetchCount(permissions.canReadSessions, '/mining_sessions/count'), + fetchCount(permissions.canReadPayouts, '/reward_payouts/count'), + fetchRows(permissions.canReadWallets, '/wallets', 25), + fetchRows(permissions.canReadPools, '/mining_pools', 25), + fetchRows(permissions.canReadConfigs, '/mining_configs', 12), + fetchRows(permissions.canReadSessions, '/mining_sessions', 12), + fetchRows(permissions.canReadPayouts, '/reward_payouts', 12), + ]); + + setSummary({ + wallets: walletsCount, + pools: poolsCount, + configs: configsCount, + devices: devicesCount, + sessions: sessionsCount, + payouts: payoutsCount, + }); + setWallets(walletRows); + setPools(poolRows); + setConfigs(configRows); + setSessions(sessionRows); + setPayouts(payoutRows); + } catch (error) { + console.error('Failed to load control center data:', error); + setPageNotice({ + tone: 'danger', + message: getErrorMessage(error), + }); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [currentUser, permissions], + ); + + React.useEffect(() => { + if (!currentUser) { + return; + } + + void loadControlData(); + }, [currentUser, loadControlData]); + + React.useEffect(() => { + setConfigForm((previousState) => { + const nextWallet = + previousState.wallet && walletOptions.some((wallet) => wallet.id === previousState.wallet) + ? previousState.wallet + : walletOptions[0]?.id || ''; + const nextPool = + previousState.pool && poolOptions.some((pool) => pool.id === previousState.pool) + ? previousState.pool + : poolOptions[0]?.id || ''; + + if (nextWallet === previousState.wallet && nextPool === previousState.pool) { + return previousState; + } + + return { + ...previousState, + wallet: nextWallet, + pool: nextPool, + }; + }); + }, [walletOptions, poolOptions]); + + const handleWalletInputChange = ( + event: React.ChangeEvent, + ) => { + const { name, value } = event.target; + setWalletForm((previousState) => ({ + ...previousState, + [name]: value, + })); + }; + + const handlePoolInputChange = ( + event: React.ChangeEvent, + ) => { + const { name, value } = event.target; + setPoolForm((previousState) => ({ + ...previousState, + [name]: value, + })); + }; + + const handleConfigInputChange = ( + event: React.ChangeEvent, + ) => { + const { name, value } = event.target; + setConfigForm((previousState) => ({ + ...previousState, + [name]: value, + })); + }; + + const validateWalletForm = () => { + const nextErrors: FormErrors = {}; + + if (!walletForm.label.trim()) { + nextErrors.label = 'Add a short label so the payout target is recognizable in dashboards.'; + } + + if (!walletForm.currency_symbol.trim()) { + nextErrors.currency_symbol = 'Enter the wallet currency symbol.'; + } + + if (!walletForm.address.trim()) { + nextErrors.address = 'Enter the destination wallet address.'; + } else if (walletForm.address.trim().length < 12) { + nextErrors.address = 'The wallet address looks too short.'; + } + + return nextErrors; + }; + + const validatePoolForm = () => { + const nextErrors: FormErrors = {}; + + if (!poolForm.pool_name.trim()) { + nextErrors.pool_name = 'Give this endpoint a descriptive pool name.'; + } + + if (!poolForm.endpoint_url.trim()) { + nextErrors.endpoint_url = 'Enter the upstream endpoint URL.'; + } + + if (!poolForm.username_template.trim()) { + nextErrors.username_template = 'Add the worker username template.'; + } + + if (!poolForm.priority.trim()) { + nextErrors.priority = 'Set the failover priority.'; + } else if (Number.isNaN(Number(poolForm.priority))) { + nextErrors.priority = 'Priority must be a number.'; + } + + return nextErrors; + }; + + const validateConfigForm = () => { + const nextErrors: FormErrors = {}; + + if (!configForm.wallet) { + nextErrors.wallet = 'Choose the payout wallet this profile should use.'; + } + + if (!configForm.pool) { + nextErrors.pool = 'Choose the pool endpoint this profile should target.'; + } + + if (!configForm.threads.trim()) { + nextErrors.threads = 'Set the number of threads available to the runtime.'; + } else if (!Number.isInteger(Number(configForm.threads)) || Number(configForm.threads) <= 0) { + nextErrors.threads = 'Threads must be a positive whole number.'; + } + + if (!configForm.cpu_usage_limit_percent.trim()) { + nextErrors.cpu_usage_limit_percent = 'Set a CPU usage guardrail.'; + } else if ( + Number(configForm.cpu_usage_limit_percent) <= 0 || + Number(configForm.cpu_usage_limit_percent) > 100 + ) { + nextErrors.cpu_usage_limit_percent = 'CPU limit must be between 1 and 100 percent.'; + } + + if (configForm.throttle_on_battery) { + if (!configForm.battery_threshold_percent.trim()) { + nextErrors.battery_threshold_percent = 'Add the battery threshold for throttling.'; + } else if ( + Number(configForm.battery_threshold_percent) <= 0 || + Number(configForm.battery_threshold_percent) > 100 + ) { + nextErrors.battery_threshold_percent = 'Battery threshold must be between 1 and 100 percent.'; + } + } + + return nextErrors; + }; + + const handleWalletSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const nextErrors = validateWalletForm(); + setWalletErrors(nextErrors); + setWalletNotice(null); + + if (Object.keys(nextErrors).length > 0 || !currentUser) { + return; + } + + setIsSubmittingWallet(true); + + try { + await axios.post('/wallets', { + data: { + ...walletForm, + user: currentUser.id, + }, + }); + setWalletForm({ + ...initialWalletForm, + address: '', + }); + setWalletNotice({ + tone: 'success', + message: 'Wallet saved. It is now available for runtime profile selection below.', + }); + await loadControlData('refresh'); + } catch (error) { + console.error('Failed to save wallet:', error); + setWalletNotice({ + tone: 'danger', + message: getErrorMessage(error), + }); + } finally { + setIsSubmittingWallet(false); + } + }; + + const handlePoolSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const nextErrors = validatePoolForm(); + setPoolErrors(nextErrors); + setPoolNotice(null); + + if (Object.keys(nextErrors).length > 0) { + return; + } + + setIsSubmittingPool(true); + + try { + await axios.post('/mining_pools', { + data: { + ...poolForm, + priority: Number(poolForm.priority), + }, + }); + setPoolForm({ + ...initialPoolForm, + endpoint_url: '', + }); + setPoolNotice({ + tone: 'success', + message: 'Pool endpoint saved. It is ready to be assigned to a runtime profile.', + }); + await loadControlData('refresh'); + } catch (error) { + console.error('Failed to save pool endpoint:', error); + setPoolNotice({ + tone: 'danger', + message: getErrorMessage(error), + }); + } finally { + setIsSubmittingPool(false); + } + }; + + const handleConfigSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const nextErrors = validateConfigForm(); + setConfigErrors(nextErrors); + setConfigNotice(null); + + if (Object.keys(nextErrors).length > 0 || !currentUser) { + return; + } + + setIsSubmittingConfig(true); + + try { + await axios.post('/mining_configs', { + data: { + user: currentUser.id, + wallet: configForm.wallet, + pool: configForm.pool, + mining_mode: configForm.mining_mode, + threads: Number(configForm.threads), + cpu_usage_limit_percent: Number(configForm.cpu_usage_limit_percent), + auto_start: configForm.auto_start, + run_in_background: configForm.run_in_background, + throttle_on_battery: configForm.throttle_on_battery, + battery_threshold_percent: configForm.throttle_on_battery + ? Number(configForm.battery_threshold_percent) + : null, + effective_from: new Date().toISOString(), + }, + }); + setConfigNotice({ + tone: 'success', + message: 'Runtime profile saved. Review it in the recent profiles list or open the full config table.', + }); + await loadControlData('refresh'); + } catch (error) { + console.error('Failed to save runtime profile:', error); + setConfigNotice({ + tone: 'danger', + message: getErrorMessage(error), + }); + } finally { + setIsSubmittingConfig(false); + } + }; + + if (isLoading) { + return ( + <> + + {getPageTitle('Control Center')} + + + +
+ + + + + ); + } + + return ( + <> + + {getPageTitle('Control Center')} + + + +
+ + +
+
+ + {pageNotice && ( + + {pageNotice.message} + + )} + + +
+
+
+
+
+
+ Browser mining admin slice +
+

+ Configure payout routing, pool endpoints, and runtime policies from one branded ops hub. +

+

+ This first iteration focuses on the legitimate admin workflow: set up where rewards go, + define pool connectivity, save safe device constraints, and monitor the latest telemetry and payout activity. +

+
+ + +
+
+
+

Observed hashrate

+

{formatNumber(aggregateHashrate, ' H/s')}

+
+
+

Recent payouts

+

{formatNumber(aggregatePayout)}

+
+
+

Tracked devices

+

{formatCount(summary.devices)}

+
+
+
+ +
+
+
+

Launch readiness

+

+ {completedSteps}/{readinessSteps.length} +

+
+ {isRefreshing && ( + + Refreshing + + )} +
+
+
+
+
+ {readinessSteps.map((step) => ( +
+
+
+ +
+
+

{step.title}

+

{step.description}

+
+
+
+ ))} +
+
+
+
+ + + + +
+ + + + + + +
+ + + +
+ +
+
+

Step 1

+

Connect payout wallet

+

+ Route future rewards to a labeled wallet record that operators can recognize at a glance. +

+
+ + Wallets + +
+
+ {walletNotice && {walletNotice.message}} + {!permissions.canCreateWallets && ( + You do not have permission to create wallets in this workspace. + )} +
+ + + + + + + + + + + + + + + + + + + + + setWalletForm((previousState) => ({ + ...previousState, + is_default: checked, + })) + } + /> + +
+ + +
+ +
+
+ + +
+
+

Step 2

+

Register pool endpoint

+

+ Capture the relay or pool connection details that browser clients should talk to. +

+
+ + Pools + +
+
+ {poolNotice && {poolNotice.message}} + {!permissions.canCreatePools && ( + You do not have permission to create pool endpoints in this workspace. + )} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setPoolForm((previousState) => ({ + ...previousState, + is_active: checked, + })) + } + /> + +
+ + +
+ +
+
+ + +
+
+

Step 3

+

Save runtime profile

+

+ Define operational guardrails for CPU usage, battery throttling, and background execution. +

+
+ + Profiles + +
+
+ {configNotice && {configNotice.message}} + {!permissions.canCreateConfigs && ( + You do not have permission to save runtime profiles in this workspace. + )} + {(walletOptions.length === 0 || poolOptions.length === 0) && permissions.canCreateConfigs && ( + + Create at least one wallet and one pool endpoint before saving a runtime profile. + + )} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + setConfigForm((previousState) => ({ + ...previousState, + run_in_background: checked, + })) + } + /> + + setConfigForm((previousState) => ({ + ...previousState, + auto_start: checked, + })) + } + /> + + setConfigForm((previousState) => ({ + ...previousState, + throttle_on_battery: checked, + })) + } + /> +
+ +
+ + +
+ +
+
+
+ + + +
+ +
+
+

Recent wallets

+

Wallet registry

+
+ +
+
+ {!permissions.canReadWallets && ( + Wallet read access is not available for this account. + )} + {permissions.canReadWallets && recentWallets.length === 0 && ( +
+ No wallets yet. Use the first card above to register the payout destination for your first profile. +
+ )} + {permissions.canReadWallets && + recentWallets.map((wallet) => ( +
+
+
+

{wallet.label || 'Unnamed wallet'}

+

+ {wallet.currency_symbol || wallet.chain || 'Crypto wallet'} · {truncateMiddle(wallet.address, 8, 6)} +

+
+ {wallet.is_default && ( + + Default + + )} +
+
+ + +
+
+ ))} +
+
+ + +
+
+

Recent profiles

+

Runtime policies

+
+ +
+
+ {!permissions.canReadConfigs && ( + Runtime profile read access is not available for this account. + )} + {permissions.canReadConfigs && recentConfigs.length === 0 && ( +
+ No runtime profiles yet. Create one above to lock in CPU limits, background behavior, and battery rules. +
+ )} + {permissions.canReadConfigs && + recentConfigs.map((config) => ( +
+
+
+

{config.mining_mode?.toUpperCase() || 'AUTO'} profile

+

+ Wallet: {config.wallet?.label || 'Unassigned'} · Pool: {config.pool?.pool_name || 'Unassigned'} +

+
+ + {formatNumber(config.threads)} threads + +
+
+
+ CPU cap: {formatNumber(config.cpu_usage_limit_percent, '%')} +
+
+ Battery throttle: {config.throttle_on_battery ? `${formatNumber(config.battery_threshold_percent, '%')}` : 'Disabled'} +
+
+
+ + +
+
+ ))} +
+
+ + +
+
+

Operations feed

+

Sessions & payouts

+
+
+ + +
+
+
+
+
+

Latest sessions

+ {!permissions.canReadSessions && Permission required} +
+
+ {permissions.canReadSessions && recentSessions.length === 0 && ( +
+ No sessions yet. Once browser clients report activity, this feed will show their latest telemetry here. +
+ )} + {permissions.canReadSessions && + recentSessions.map((session) => ( +
+
+
+

{session.device?.device_name || 'Unknown device'}

+

+ {session.mining_config?.mining_mode?.toUpperCase() || 'AUTO'} · Avg {formatNumber(session.avg_hashrate_hs, ' H/s')} +

+
+ + {session.status || 'Unknown'} + +
+
+ Started {formatDate(session.started_at)} + +
+
+ ))} +
+
+ + + +
+
+

Latest payouts

+ {!permissions.canReadPayouts && Permission required} +
+
+ {permissions.canReadPayouts && recentPayouts.length === 0 && ( +
+ No payout records yet. Once rewards are recorded, they will appear here with status and traceability. +
+ )} + {permissions.canReadPayouts && + recentPayouts.map((payout) => ( +
+
+
+

{payout.wallet?.label || 'Wallet pending'}

+

+ {formatNumber(payout.amount)} {payout.wallet?.currency_symbol || ''} + {payout.tx_hash ? ` · ${truncateMiddle(payout.tx_hash, 8, 6)}` : ''} +

+
+ + {payout.status || 'Unknown'} + +
+
+ {payout.paid_at ? `Paid ${formatDate(payout.paid_at)}` : 'Awaiting payout timestamp'} + +
+
+ ))} +
+
+
+
+
+ + + ); +}; + +ControlCenter.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default ControlCenter; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 9d1a25a..253a760 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -6,6 +6,9 @@ import type { ReactElement } from 'react' import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import BaseButton from '../components/BaseButton' +import BaseDivider from '../components/BaseDivider' +import CardBox from '../components/CardBox' import BaseIcon from "../components/BaseIcon"; import { getPageTitle } from '../config' import Link from "next/link"; @@ -485,6 +488,25 @@ const Dashboard = () => {
+ + + + +
+
+

New first-slice workflow

+

Open the Control Center

+

+ Set up payout wallets, register pool endpoints, save runtime guardrails, and review recent + sessions and reward payouts from one focused admin flow. +

+
+
+ + +
+
+
) diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 30fbbc7..200b51d 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,273 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import * as icon from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const featureCards = [ + { + title: 'Wallet routing', + description: + 'Register reward destinations, mark the default wallet, and keep payout targets visible for operators.', + iconName: 'mdiWallet', + accentClass: 'from-cyan-500/40 to-sky-500/10', + }, + { + title: 'Runtime profiles', + description: + 'Capture CPU caps, background behavior, and battery throttling so device usage stays controlled.', + iconName: 'mdiTune', + accentClass: 'from-violet-500/40 to-fuchsia-500/10', + }, + { + title: 'Session telemetry', + description: + 'Review recent sessions, hashrate snapshots, and payout records inside the admin workspace.', + iconName: 'mdiChartLine', + accentClass: 'from-emerald-500/40 to-teal-500/10', + }, +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); +const workflowSteps = [ + { + step: '01', + title: 'Connect a payout wallet', + body: 'Give operators a clear destination for rewards and a human-friendly label for audits.', + }, + { + step: '02', + title: 'Register a pool endpoint', + body: 'Capture the upstream protocol, URL, and worker template that the control plane should use.', + }, + { + step: '03', + title: 'Save a runtime policy', + body: 'Define threads, CPU limits, battery throttling, and background execution in one place.', + }, + { + step: '04', + title: 'Monitor sessions and payouts', + body: 'Review the latest telemetry and reward activity from the same branded operations hub.', + }, +]; - const title = 'Crypto Miner Monitor' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; +const getMdi = (name: string, fallback = icon.mdiTable) => { + return (name in icon ? icon[name as keyof typeof icon] : fallback) as string; +}; +export default function LandingPage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Crypto Miner Monitor')} +
+
+
- -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+

+ Crypto Miner Monitor +

+

+ Dark, modern operations software for payout routing, runtime setup, and browser-session visibility. +

- - - +
+ + +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+ Browser mining control plane +
+

+ Operate the workflow around browser mining without losing visibility. +

+

+ The first MVP slice delivers a polished admin experience: connect wallets, define pool connectivity, + save safe runtime constraints, and monitor the latest sessions and reward payouts from one place. +

+
+ + +
+
+
+

Setup flow

+

3 steps

+

Wallet, pool endpoint, and runtime profile creation.

+
+
+

Visibility

+

Live feed

+

Recent sessions, payouts, and readiness status.

+
+
+

Admin access

+

Protected

+

Use the login flow to enter the control workspace.

+
+
+
-
+ +
+
+
+
+

Preview

+

Control center

+
+ + Ready for ops + +
+
+
+
+
+

Payout routing

+

+ Assign a default wallet and keep reward destinations audit-friendly. +

+
+
+ +
+
+
+
+
+
+

Runtime guardrails

+

+ Threads, CPU limits, and battery throttling live together in a single policy screen. +

+
+
+ +
+
+
+
+
+
+

Telemetry + payouts

+

+ Review recent sessions and payout records without bouncing between generic CRUD pages. +

+
+
+ +
+
+
+
+
+ + + +
+
+
+

What is included

+

+ A practical admin workflow for the first release. +

+
+ + Sign in to continue → + +
+
+ {featureCards.map((feature) => ( + +
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+ +
+
+

Operator journey

+

From setup to monitoring in one thin slice.

+
+
+ {workflowSteps.map((item) => ( +
+

Step {item.step}

+

{item.title}

+

{item.body}

+
+ ))} +
+
+ +
+
+
+
+

Admin entry

+

+ Ready to explore the first iteration? +

+

+ Use the protected admin area to open the new Control Center, create the core records, and review the seeded mining-related entities in context. +

+
+
+ + +
+
+
+
+ + +
+
+

© 2026 Crypto Miner Monitor. Built for monitoring, setup, and operator visibility.

+
+ + Privacy Policy + + + Terms of Use + + + Admin Interface + +
+
+
+
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +LandingPage.getLayout = function getLayout(page: ReactElement) { return {page}; }; -