Allows users to sort monitors in any order

This commit is contained in:
ksjaay
2025-12-02 23:35:03 +00:00
committed by KSJaay
parent ebe999643a
commit 11c8cdd7d1
14 changed files with 375 additions and 83 deletions

View File

@@ -0,0 +1,33 @@
import { Dropdown } from '@lunalytics/ui';
import { FaEllipsisVertical, FaSort } from 'react-icons/fa6';
import NavigationReorderModal from '../modal/navigation/reorder';
import useContextStore from '../../context';
import { observer } from 'mobx-react-lite';
const HomeMenu = () => {
const {
modalStore: { openModal, closeModal },
} = useContextStore();
return (
<Dropdown
items={[
{
id: 'customize-dashboard',
text: 'Reorder Monitors',
type: 'item',
icon: (<FaSort />) as React.ReactNode,
onClick: () => {
openModal(<NavigationReorderModal closeModal={closeModal} />);
},
},
]}
hideIcon
position="top"
>
<FaEllipsisVertical size={20} />
</Dropdown>
);
};
export default observer(HomeMenu);

View File

@@ -7,6 +7,7 @@ import useContextStore from '../../context';
import PillCircle from '../navigation/PillCircle';
import HomeMonitorsListContext from './context';
import type { ContextMonitorProps } from '../../types/context/global';
import { getMonitorsInOrder } from '../modal/navigation/reorder';
const HomeMonitorsList = ({
monitors = [],
@@ -14,71 +15,73 @@ const HomeMonitorsList = ({
monitors: ContextMonitorProps[];
}) => {
const {
globalStore: { activeMonitor, setActiveMonitor },
userStore: { user },
globalStore: { activeMonitor, setActiveMonitor, getMonitor },
} = useContextStore();
const monitorsList = monitors
.sort((a, b) => a.name.localeCompare(b.name))
.map((monitor) => {
const classes = classNames('item', {
'item-active': activeMonitor?.monitorId === monitor.monitorId,
});
const monitorsList = getMonitorsInOrder(
monitors,
user?.settings?.monitorsList
).map((item) => {
const monitor = getMonitor(item.monitorId);
let heartbeats: string[] = monitor.heartbeats
.map((heartbeat) => {
const downColor = monitor.paused
? 'var(--red-900)'
: 'var(--red-800)';
const upColor = monitor.paused
? 'var(--green-900)'
: 'var(--green-800)';
const classes = classNames('item', {
'item-active': activeMonitor?.monitorId === monitor.monitorId,
});
return heartbeat.isDown ? downColor : upColor;
})
.splice(0, 12);
let heartbeats: string[] = monitor.heartbeats
.map((heartbeat) => {
const downColor = monitor.paused ? 'var(--red-900)' : 'var(--red-800)';
const upColor = monitor.paused
? 'var(--green-900)'
: 'var(--green-800)';
if (heartbeats.length < 12) {
const length = 12 - heartbeats.length;
const emptyHeartbeats = Array.from({ length }).map(
() => 'var(--gray-500)'
);
return heartbeat.isDown ? downColor : upColor;
})
.splice(0, 12);
heartbeats = heartbeats.concat(emptyHeartbeats);
}
if (heartbeats.length < 12) {
const length = 12 - heartbeats.length;
const emptyHeartbeats = Array.from({ length }).map(
() => 'var(--gray-500)'
);
const iconUrl = monitor.icon?.url || '/logo.svg';
heartbeats = heartbeats.concat(emptyHeartbeats);
}
return (
<HomeMonitorsListContext
monitorId={monitor.monitorId}
key={monitor.monitorId}
const iconUrl = monitor.icon?.url || '/logo.svg';
return (
<HomeMonitorsListContext
monitorId={monitor.monitorId}
key={monitor.monitorId}
>
<div
className={classes}
onClick={() => setActiveMonitor(monitor.monitorId)}
>
<div
className={classes}
onClick={() => setActiveMonitor(monitor.monitorId)}
>
<div className="content">
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={iconUrl} style={{ width: '35px' }} />
</div>
<div>
<div>{monitor.name}</div>
<span>{monitor.url}</span>
</div>
<div className="content">
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={iconUrl} style={{ width: '35px' }} />
</div>
<div className="pill-container">
<PillCircle pills={heartbeats} />
<div>
<div>{monitor.name}</div>
<span>{monitor.url}</span>
</div>
</div>
</HomeMonitorsListContext>
);
});
<div className="pill-container">
<PillCircle pills={heartbeats} />
</div>
</div>
</HomeMonitorsListContext>
);
});
return <div className="navigation-monitor-items">{monitorsList}</div>;
};

View File

@@ -0,0 +1,185 @@
// import dependencies
import { createSwapy } from 'swapy';
import { toast } from 'react-toastify';
import { useEffect, useRef } from 'react';
import { observer } from 'mobx-react-lite';
import { Button, Modal } from '@lunalytics/ui';
import { PiDotsSixVerticalBold } from 'react-icons/pi';
// import local files
import useContextStore from '../../../context';
import { createPostRequest } from '../../../services/axios';
interface NavigationReorderModalProps {
closeModal: () => void;
}
export const getMonitorsInOrder = (
allMonitors: Array<any>,
monitorsList: Array<{
type: 'monitor' | 'folder';
isHidden: boolean;
monitorId: string;
}>
) => {
let userMonitors = monitorsList || [];
allMonitors.forEach((monitor) => {
if (!userMonitors.find((m) => m.monitorId === monitor.monitorId)) {
userMonitors.push({
type: 'monitor',
isHidden: false,
monitorId: monitor.monitorId,
});
}
});
return userMonitors;
};
const NavigationReorderModal = ({
closeModal,
}: NavigationReorderModalProps) => {
const {
globalStore: { allMonitors },
userStore: { user, updateUsingKey },
} = useContextStore();
const swapy = useRef<any>(null);
const container = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (container.current) {
swapy.current = createSwapy(container.current);
}
return () => {
swapy.current?.destroy();
};
}, []);
const monitorOrder = getMonitorsInOrder(
allMonitors,
user?.settings?.monitorsList
);
const handleConfirm = async () => {
const order = swapy.current
.slotItemMap()
.asArray.map((obj: any) =>
monitorOrder.find((m: any) => m.monitorId === obj.item)
)
.filter(Boolean);
await createPostRequest('/api/user/update/settings', {
settings: {
...user?.settings,
monitorsList: order,
},
});
updateUsingKey('settings', {
...user.settings,
monitorsList: order,
});
toast.success('Monitor order updated successfully');
closeModal();
};
return (
<Modal
onClose={closeModal}
size="sm"
height="sm"
title="Reorder Monitors"
actions={
<>
<Button
color="red"
variant="flat"
id="home-reorder-cancel-button"
onClick={closeModal}
>
Cancel
</Button>
<Button
color="green"
variant="flat"
id="home-reorder-confirm-button"
onClick={handleConfirm}
>
Confirm
</Button>
</>
}
>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
ref={container}
>
{monitorOrder.map((item) => {
const monitor = allMonitors.find(
(m) => m.monitorId === item.monitorId
);
if (!monitor) return null;
return (
<div data-swapy-slot={item.monitorId} key={item.monitorId}>
<div
style={{
display: 'flex',
border: '2px solid var(--accent-700)',
padding: '8px 12px',
borderRadius: '8px',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'var(--accent-800)',
}}
data-swapy-item={item.monitorId}
>
<div
style={{
display: 'flex',
gap: '6px',
fontSize: 'var(--font-lg)',
}}
>
<img
src={monitor.icon.url}
alt={monitor.name}
style={{ width: '24px', height: '24px' }}
/>
<div>{monitor.name}</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '4px',
}}
>
{/* {monitorOrder.find((m) => m.monitorId === item.monitorId)
?.isHidden ? (
<IoMdEyeOff size={24} />
) : (
<IoMdEye size={24} />
)} */}
<PiDotsSixVerticalBold
style={{ width: '24px', height: '24px' }}
/>
</div>
</div>
</div>
);
})}
</div>
</Modal>
);
};
NavigationReorderModal.displayName = 'NavigationReorderModal';
export default observer(NavigationReorderModal);

View File

@@ -6,7 +6,6 @@ import { Button } from '@lunalytics/ui';
import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
import { useEffect, useMemo, useState } from 'react';
// import { FaEllipsisVertical } from 'react-icons/fa6';
// import local files
import useContextStore from '../context';
@@ -22,6 +21,7 @@ import MonitorConfigureModal from '../components/modal/monitor/configure';
import NavigationMonitorInfo from '../components/navigation/info/monitor';
import useScreenSize from '../hooks/useScreenSize';
import { filterData } from '../../shared/utils/search';
import HomeMenu from '../components/home/menu';
const Home = () => {
const {
@@ -96,9 +96,7 @@ const Home = () => {
>
{t('home.monitor.add')}
</Button>
{/* <div className="monitor-left-menu">
<FaEllipsisVertical size={20} />
</div> */}
<HomeMenu />
</div>
}
rightChildren={

View File

@@ -8,7 +8,7 @@ export interface NotificationInputInputType {
| 'group'
| 'textarea'
| 'empty';
isDataField: boolean;
isDataField?: boolean;
key: string;
title: string;
placeholder?: string;

View File

@@ -1,3 +1,11 @@
export interface UserSettings {
monitorsList?: Array<{
type: 'monitor' | 'folder';
isHidden: boolean;
monitorId: string;
}>;
}
export interface ContextUserProps {
email: string;
displayName: string;
@@ -6,4 +14,5 @@ export interface ContextUserProps {
permission: number;
createdAt: string;
isOwner: boolean;
settings: UserSettings;
}

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "lunalytics",
"version": "0.10.14",
"version": "0.10.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lunalytics",
"version": "0.10.14",
"version": "0.10.16",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@dnd-kit/core": "6.3.1",
@@ -9172,9 +9172,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "lunalytics",
"version": "0.10.14",
"version": "0.10.16",
"description": "Open source Node.js server/website monitoring tool",
"private": true,
"author": "KSJaay <ksjaay@gmail.com>",

View File

@@ -0,0 +1,22 @@
// import local files
import SQLite from '../../server/database/sqlite/setup.js';
import logger from '../../server/utils/logger.js';
const infomation = {
title: 'Adds settings column to user table',
description: 'Adds settings column to user table to store user preferences',
version: '0.10.16',
};
const migrate = async () => {
const client = await SQLite.connect();
await client.schema.alterTable('user', (table) => {
table.jsonb('settings').defaultTo(JSON.stringify({}));
});
logger.info('Migrations', { message: '0.10.16 has been applied' });
return;
};
export { infomation, migrate };

View File

@@ -12,6 +12,7 @@ import { migrate as migrateMonitor } from './0-9-5.js';
import { migrate as migrateMonitorJson } from './0-9-7.js';
import { migrate as migrateUiOverhaul } from './0-10-0.js';
import { migrate as migrateMonitorParent } from './0-10-13.js';
import { migrate as migrateUserSettings } from './0-10-16.js';
const migrationList = {
'0.4.0': migrateTcpUpdate,
@@ -27,6 +28,7 @@ const migrationList = {
'0.9.7': migrateMonitorJson,
'0.10.0': migrateUiOverhaul,
'0.10.13': migrateMonitorParent,
'0.10.16': migrateUserSettings,
};
export default migrationList;

View File

@@ -79,7 +79,7 @@ export const registerSsoUser = async (data) => {
};
export const getUserByEmail = async (email) => {
return SQLite.client('user')
let user = await SQLite.client('user')
.where({ email })
.select(
'email',
@@ -89,25 +89,20 @@ export const getUserByEmail = async (email) => {
'permission',
'createdAt',
'isOwner',
'sso'
'sso',
'settings'
)
.first();
};
export const emailExists = async (email) => {
return SQLite.client('user')
.where({ email })
.select(
'email',
'displayName',
'avatar',
'isVerified',
'permission',
'createdAt',
'isOwner',
'sso'
)
.first();
if (user && user.settings) {
try {
user.settings = JSON.parse(user.settings);
} catch {
user.settings = {};
}
}
return user;
};
export const emailIsOwner = async (email) => {
@@ -115,7 +110,7 @@ export const emailIsOwner = async (email) => {
};
export const ownerExists = async () => {
return SQLite?.client('user')
let user = await SQLite?.client('user')
.where({ permission: oldPermsToFlags[1] })
.select(
'email',
@@ -125,9 +120,20 @@ export const ownerExists = async () => {
'permission',
'createdAt',
'isOwner',
'sso'
'sso',
'settings'
)
.first();
if (user && user.settings) {
try {
user.settings = JSON.parse(user.settings);
} catch {
user.settings = {};
}
}
return user;
};
export const getUserPasswordUsingEmail = async (email) => {
@@ -211,6 +217,12 @@ export const updateUserPassword = (email, password) => {
.update({ password: hashedPassword });
};
export const updateUserSettings = (email, settings) => {
return SQLite.client('user')
.where({ email })
.update({ settings: JSON.stringify(settings) });
};
export const resetDemoUser = async () => {
const demoUser = await SQLite.client('user').where({ email: 'demo' }).first();

View File

@@ -14,6 +14,7 @@ export const userTable = async (client) => {
table.boolean('sso').defaultTo(0);
table.integer('permission').defaultTo(oldPermsToFlags[4]);
table.datetime('createdAt');
table.jsonb('settings').defaultTo(JSON.stringify({}));
table.index('email');
table.index('isVerified');

View File

@@ -0,0 +1,24 @@
import { updateUserSettings } from '../../../database/queries/user.js';
import { handleError } from '../../../utils/errors.js';
const userUpdateSettings = async (request, response) => {
try {
const { settings } = request.body;
if (
typeof settings !== 'object' ||
settings === null ||
Array.isArray(settings)
) {
return response.sendStatus(400);
}
await updateUserSettings(response.locals.user.email, settings);
return response.sendStatus(200);
} catch (error) {
handleError(error, response);
}
};
export default userUpdateSettings;

View File

@@ -19,6 +19,7 @@ import { hasRequiredPermission } from '../middleware/user/hasPermission.js';
import deleteConnectionMiddleware from '../middleware/user/connections/delete.js';
import getAllConnectionMiddleware from '../middleware/user/connections/getAll.js';
import createConnectionMiddleware from '../middleware/user/connections/create.js';
import userUpdateSettings from '../middleware/user/update/settings.js';
router.get('/', fetchUserMiddleware);
@@ -38,6 +39,8 @@ router.post('/update/password', userUpdatePassword);
router.post('/update/avatar', userUpdateAvatar);
router.post('/update/settings', userUpdateSettings);
router.get('/team', teamMembersListMiddleware);
router.get('/connections', getAllConnectionMiddleware);