added device actions

This commit is contained in:
Carter 2024-12-13 13:27:16 -06:00
parent cd986c4f56
commit aa3301ac35
10 changed files with 557 additions and 15 deletions

34
src/models/Tag.ts Normal file
View file

@ -0,0 +1,34 @@
import { pond } from "protobuf-ts/pond";
import { or } from "utils/types";
import { cloneDeep } from "lodash";
export class Tag {
public settings: pond.TagSettings = pond.TagSettings.create();
public static create(pb?: pond.Tag): Tag {
let my = new Tag();
if (pb) {
my.settings = pond.TagSettings.fromObject(cloneDeep(or(pb.settings, {})));
}
return my;
}
public static clone(other?: Tag): Tag {
if (other) {
return Tag.create(pond.Tag.fromObject({ settings: cloneDeep(other.settings) }));
}
return Tag.create();
}
public static any(data: any): Tag {
return Tag.create(pond.Tag.fromObject(cloneDeep(data)));
}
public key(): string {
return this.settings.key;
}
public name(): string {
return this.settings.name !== "" ? this.settings.name : "Tag " + this.settings.key;
}
}

View file

@ -9,7 +9,7 @@ export * from "./Device";
export * from "./Scope";
export * from "./ShareableLink";
// export * from "./Site";
// export * from "./Tag";
export * from "./Tag";
// export * from "./Task";
// export * from "./Upgrade";
// export * from "./User";

View file

@ -1,14 +1,17 @@
import { Theme, Typography } from "@mui/material";
import { Grid2, Theme, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles";
import { Device } from "models";
import { Device, User } from "models";
import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import { useDeviceAPI, useSnackbar } from "providers";
import { useDeviceAPI, useGlobalState, useSnackbar } from "providers";
import { useEffect, useState } from "react";
import { useLocation, useParams } from "react-router-dom";
import PageContainer from "./PageContainer";
import { useMobile } from "hooks";
import LoadingScreen from "app/LoadingScreen";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import DeviceActions from "common/DeviceActions";
import { pond } from "protobuf-ts/pond";
import { cloneDeep } from "lodash";
const useStyles = makeStyles((theme: Theme) => {
// const isMobile = useMobile()
@ -24,19 +27,53 @@ export default function DevicePage() {
const isMobile = useMobile()
const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? "";
const { state } = useLocation();
const [{ as, team, user }] = useGlobalState()
const [device, setDevice] = useState<Device>(state?.device ? Device.create(state.device) : Device.create())
const [loading, setLoading] = useState(false)
const [permissions, setPermissions] = useState<pond.Permission[]>([])
const [preferences, setPreferences] = useState<pond.DevicePreferences>(pond.DevicePreferences.create())
const loadDevice = () => {
if (loading) return
if (state?.device && parseInt(state.device.settings.deviceId) === device.id()) return
deviceAPI.get(deviceID, false, getContextKeys(), getContextTypes()).then(resp => {
setDevice(Device.create(resp.data))
}).catch(() => {
snackbar.error("Error loading device.")
}).finally(() => {
setLoading(false)
// if (state?.device && parseInt(state.device.settings.deviceId) === device.id()) return
// deviceAPI.get(deviceID, false, getContextKeys(), getContextTypes()).then(resp => {
// setDevice(Device.create(resp.data))
// console.log(resp.data)
// }).catch(() => {
// snackbar.error("Error loading device.")
// }).finally(() => {
// setLoading(false)
// })
setLoading(true)
deviceAPI.getPageData(deviceID, getContextKeys(), getContextTypes()).then(resp => {
setDevice(Device.any(resp.data.device))
setPermissions(resp.data.permissions)
let u = User.any(resp.data.user);
setPreferences(u.preferences)
console.log(u.preferences)
}).catch(err => {
setDevice(Device.create());
// setComponents(new Map());
// setInteractions([]);
// setAvailablePositions(new Map());
setPermissions([]);
setPreferences(pond.UserPreferences.create());
// setInvalidDevice(true);
// error(err);
if (err?.response?.data?.error.includes("not found")) {
let name = as === team.key() ? team.name() : user.name();
let warningString = name + " not permitted to view Device " + deviceID;
let length = getContextTypes().length;
if (length > 0) {
warningString = warningString + " through " + getContextTypes()[length - 1];
}
snackbar.warning(warningString);
} else {
snackbar.error(err);
}
})
.finally(() => setLoading(false));
}
useEffect(() => {
@ -47,6 +84,21 @@ export default function DevicePage() {
loadDevice()
}, [])
const toggleNotificationPreference = () => {
let updatedPreferences = cloneDeep(preferences);
updatedPreferences.notify = !preferences.notify;
deviceAPI
.updatePreferences(deviceID, updatedPreferences, getContextKeys(), getContextTypes())
.then(() => setPreferences(updatedPreferences))
.catch(() => {
snackbar.error(
"Error occured while " +
(preferences.notify ? "enabling" : "disabling") +
" notifications"
);
});
};
if (loading) return (
<LoadingScreen message="Loading device"/>
)
@ -56,7 +108,22 @@ export default function DevicePage() {
{/* <Typography>
{device.name()}
</Typography> */}
<SmartBreadcrumb />
<Grid2 container justifyContent={"space-between"}>
<Grid2>
<SmartBreadcrumb />
</Grid2>
<Grid2>
<DeviceActions
device={device}
isPaused={false}
isLoading={loading}
permissions={permissions}
preferences={preferences}
toggleNotificationPreference={toggleNotificationPreference}
refreshCallback={loadDevice}
/>
</Grid2>
</Grid2>
</PageContainer>
);
}

View file

@ -2,6 +2,7 @@ import { Avatar, AvatarGroup, Box, Button, Card, Grid2, Theme, Typography } from
import { makeStyles } from "@mui/styles";
import LoadingScreen from "app/LoadingScreen";
import Chat from "chat/Chat";
// import SmartBreadcrumb from "common/SmartBreadcrumb";
import { useMobile } from "hooks";
import { cloneDeep } from "lodash";
import { Team, teamScope, User } from "models";
@ -159,6 +160,7 @@ export default function TeamPage() {
<Grid2 >
<Typography variant={isMobile ? "h5" : "h4"} className={classes.ellipsis} >
{team.name()}
{/* <SmartBreadcrumb /> */}
</Typography>
</Grid2>
<Grid2>

149
src/pbHelpers/Device.ts Normal file
View file

@ -0,0 +1,149 @@
import { Device, Tag } from "models";
import moment from "moment";
import { filterByTags } from "pbHelpers/Tag";
import { GetDeviceProductLabel } from "products/DeviceProduct";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import { capitalize } from "utils/strings";
import { getDevicePlatformLabel } from "./DevicePlatform";
import { filterByDeviceState } from "./DeviceState";
import { filterByPower } from "./Power";
import { getUpgradeChannelLabel } from "./UpgradeChannel";
//complex filter algorithm for searching a list of devices
export function filterDevices(searchValue: string, devices: Device[], tags: Tag[]): Device[] {
return devices.filter(device =>
filterDevice(searchValue.toLowerCase(), device, deviceTags(device, tags))
);
}
//helper function for filterDevices
export function filterDevice(searchValue: string, device: Device, tags: Tag[]): boolean {
const idMatch: boolean = filterByDeviceID(searchValue, device.settings.deviceId.toString());
const nameMatch: boolean = filterByDeviceName(searchValue, device.name());
const tagsMatch: boolean = filterByTags(searchValue, tags);
const deviceStateMatch: boolean = filterByDeviceState(searchValue, device.status.state);
const powerMatch: boolean = filterByPower(
searchValue,
device.status.power ? pond.DevicePower.create(device.status.power) : undefined
);
const lastActiveMatch: boolean = filterByLastActive(searchValue, device.status.lastActive);
return idMatch || nameMatch || tagsMatch || powerMatch || deviceStateMatch || lastActiveMatch;
}
export function filterByDeviceID(searchValue: string, deviceIDString: string): boolean {
return searchValue.replace("id:", "").trim() === deviceIDString;
}
export function filterByDeviceName(searchValue: string, deviceName: string): boolean {
return (
deviceName.toLowerCase().indexOf(
searchValue
.replace("name:", "")
.trim()
.toLowerCase()
) > -1
);
}
export function filterByDeviceDescription(searchValue: string, deviceDescription: string): boolean {
return (
deviceDescription.toLowerCase().indexOf(
searchValue
.replace("description:", "")
.trim()
.toLowerCase()
) > -1
);
}
export function filterByLastActive(searchValue: string, lastActive: string): boolean {
return (
moment(lastActive)
.fromNow()
.indexOf(searchValue.replace("last active:", "")) > -1
);
}
export function deviceTags(device: Device, tags: Tag[]): Tag[] {
return tags.filter(tag => device.status.tagKeys.some(key => key === tag.settings.key));
}
const keyTranslator = new Map<keyof pond.DeviceSettings, string>([
["deviceId", "Device ID"],
["name", "Name"],
["description", "Description"],
["sleepType", "Sleep Type"],
["sleepDurationS", "Sleep Duration"],
["sleepDelayMs", "Sleep Delay"],
["pondCheckPeriodS", "Check Period"],
["removed", "Removed"],
["platform", "Platform"],
["wakePin", "Wake Pin"],
["platform", "Platform"],
["automaticallyUpgrade", "Upgrades Automatically"],
["product", "Product"],
["upgradeChannel", "Upgrade Channel"],
["latitude", "Latitude"],
["longitude", "Longitude"],
["theme", "Marker Theme"],
["extensionComponents", "Extension Components"],
["mutations", "Device Mutations"]
]);
// Keys will be stringified by default if not found in the keyTranslator
export function TranslateKey(key: keyof pond.DeviceSettings): string {
let translatedKey = keyTranslator.get(key);
return translatedKey ? translatedKey : capitalize(key.toString());
}
const valueTranslator = new Map<keyof pond.DeviceSettings, (device: pond.DeviceSettings) => string>(
[
["name", device => device.name],
["description", device => device.description],
[
"sleepType",
device => {
switch (device.sleepType) {
case quack.SleepType.SLEEP_TYPE_DEEP:
return "Deep Sleep";
case quack.SleepType.SLEEP_TYPE_HIBERNATE:
return "Hibernate";
case quack.SleepType.SLEEP_TYPE_NAP:
return "Nap";
default:
return "None";
}
}
],
["sleepDurationS", device => device.sleepDurationS.toString() + "s"],
["sleepDelayMs", device => (device.sleepDelayMs / 1000).toString() + "s"],
["pondCheckPeriodS", device => device.pondCheckPeriodS.toString() + "s"],
["platform", device => getDevicePlatformLabel(device.platform)],
["product", device => GetDeviceProductLabel(device.product)],
["upgradeChannel", device => getUpgradeChannelLabel(device.upgradeChannel)],
["automaticallyUpgrade", device => device.automaticallyUpgrade.toString()],
["latitude", device => device.latitude.toString()],
["longitude", device => device.longitude.toString()],
["theme", device => "Colour: " + device.theme?.color + "Size: " + device.theme?.height],
["extensionComponents", device => device.extensionComponents.toString()],
["mutations", device => device.mutations.toString()]
]
);
// Values will be stringified by default if its key is not found in the valueTranslator
export function TranslateValue(key: keyof pond.DeviceSettings, device: pond.DeviceSettings) {
//console.log(device)
//console.log(key)
let translatorFunc = valueTranslator.get(key);
let dKey = device[key];
return translatorFunc ? translatorFunc(device) : capitalize(dKey ? dKey.toString() : "");
}
//checks the user's path to determine if they are on a shared url
export function isShareableLink(rawDeviceID: string): boolean {
if (!rawDeviceID) {
return false;
}
return isNaN(Number(rawDeviceID));
}

128
src/pbHelpers/Power.tsx Normal file
View file

@ -0,0 +1,128 @@
import {
Battery20,
Battery30,
Battery50,
Battery60,
Battery80,
Battery90,
BatteryAlert,
BatteryCharging20,
BatteryCharging30,
BatteryCharging50,
BatteryCharging60,
BatteryCharging80,
BatteryCharging90,
BatteryChargingFull,
BatteryFull,
BatteryUnknown,
PowerOff,
Power
} from "@mui/icons-material";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
export interface PowerDescriber {
description: string;
icon: any;
}
interface batteryThreshold {
threshold: number;
icon: any;
}
let chargingThresholds: batteryThreshold[] = [
{ threshold: 100, icon: <BatteryChargingFull style={{ color: "var(--status-ok)" }} /> },
{ threshold: 90, icon: <BatteryCharging90 style={{ color: "var(--status-ok)" }} /> },
{ threshold: 80, icon: <BatteryCharging80 style={{ color: "var(--status-ok)" }} /> },
{ threshold: 60, icon: <BatteryCharging60 style={{ color: "var(--status-unstable)" }} /> },
{ threshold: 40, icon: <BatteryCharging50 style={{ color: "var(--status-unstable)" }} /> },
{ threshold: 30, icon: <BatteryCharging30 style={{ color: "var(--status-warning)" }} /> },
{ threshold: 20, icon: <BatteryCharging20 style={{ color: "var(--status-warning)" }} /> },
{ threshold: 1, icon: <BatteryCharging20 style={{ color: "var(--status-alert)" }} /> },
{ threshold: 0, icon: <BatteryAlert style={{ color: "var(--status-alert)" }} /> }
];
let notChargingThresholds: batteryThreshold[] = [
{ threshold: 100, icon: <BatteryFull style={{ color: "var(--status-ok)" }} /> },
{ threshold: 90, icon: <Battery90 style={{ color: "var(--status-ok)" }} /> },
{ threshold: 80, icon: <Battery80 style={{ color: "var(--status-ok)" }} /> },
{ threshold: 60, icon: <Battery60 style={{ color: "var(--status-unstable)" }} /> },
{ threshold: 40, icon: <Battery50 style={{ color: "var(--status-unstable)" }} /> },
{ threshold: 30, icon: <Battery30 style={{ color: "var(--status-warning)" }} /> },
{ threshold: 20, icon: <Battery20 style={{ color: "var(--status-warning)" }} /> },
{ threshold: 1, icon: <Battery20 style={{ color: "var(--status-alert)" }} /> },
{ threshold: 0, icon: <BatteryAlert style={{ color: "var(--status-alert)" }} /> }
];
export function describePower(power?: pond.DevicePower | null): PowerDescriber {
let result = {
description: "",
icon: <BatteryUnknown style={{ color: "var(--status-unknown)" }} />
};
if (!power) return result;
if (power.type === quack.PowerSubtype.POWER_SUBTYPE_NO_BATTERY) {
if (power.inputVoltage > 0) {
result.icon = <Power style={{ color: "var(--status-ok)" }} />;
result.description = "Plugged in";
} else {
result.icon = <PowerOff style={{ color: "var(--status-alert)" }} />;
result.description = "Unplugged";
}
} else {
let thresholds = notChargingThresholds;
let suffix = "not charging";
if (power.inputVoltage >= 4) {
thresholds = chargingThresholds;
if (power.chargePercent === 100) {
suffix = "charged";
} else {
suffix = "charging";
}
}
result.description = power.chargePercent.toString() + "%, " + suffix;
for (let i = 0; i < thresholds.length; i++) {
if (power.chargePercent >= thresholds[i].threshold) {
result.icon = thresholds[i].icon;
break;
}
}
}
return result;
}
//returns a boolean indicating whether the search term matches a power query
export function filterByPower(searchValue: string, power: pond.DevicePower | undefined): boolean {
let matches: string[] = [];
if (power) {
switch (power.type) {
case quack.PowerSubtype.POWER_SUBTYPE_NO_BATTERY:
if (power.inputVoltage > 0) {
matches.push("on");
} else {
matches.push("off");
}
break;
default:
const isCharging = power.inputVoltage >= 4.0;
if (isCharging) {
matches.push("charging");
}
if (power.chargePercent >= 0 && power.chargePercent <= 33) {
matches.push("low");
} else if (power.chargePercent <= 66) {
matches.push("medium");
} else {
matches.push("high");
}
break;
}
} else {
matches = ["unknown", "?"];
}
return matches.some(match => match.indexOf(searchValue.replace("power:", "").trim()) > -1);
}

19
src/pbHelpers/Tag.tsx Normal file
View file

@ -0,0 +1,19 @@
import { Tag } from "models";
export function filterByTags(searchValue: string, tags: Tag[]): boolean {
return tags.some(tag => filterByTag(searchValue, tag));
}
export function filterByTag(searchValue: string, tag: Tag): boolean {
return (
tag
.name()
.toLowerCase()
.indexOf(
searchValue
.replace("tag:", "")
.trim()
.toLowerCase()
) > -1
);
}

View file

@ -0,0 +1,54 @@
import { pond } from "protobuf-ts/pond";
export interface UpgradeChannelHelper {
label: string;
colour: string;
}
const Unknown: UpgradeChannelHelper = {
label: "Unknown Channel",
colour: "var(--status-ok)"
};
const Alpha: UpgradeChannelHelper = {
label: "Alpha",
colour: "var(--status-risk)"
};
const Beta: UpgradeChannelHelper = {
label: "Beta",
colour: "var(--status-unstable)"
};
const Stable: UpgradeChannelHelper = {
label: "Stable",
colour: "var(--status-ok)"
};
const Development: UpgradeChannelHelper = {
label: "Development",
colour: "var(--status-warning)"
};
export function getUpgradeChannelHelper(channel: pond.UpgradeChannel): UpgradeChannelHelper {
switch (channel) {
case pond.UpgradeChannel.UPGRADE_CHANNEL_ALPHA:
return Alpha;
case pond.UpgradeChannel.UPGRADE_CHANNEL_BETA:
return Beta;
case pond.UpgradeChannel.UPGRADE_CHANNEL_STABLE:
return Stable;
case pond.UpgradeChannel.UPGRADE_CHANNEL_DEVELOPMENT:
return Development;
default:
return Unknown;
}
}
export function getUpgradeChannelLabel(channel: pond.UpgradeChannel): string {
return getUpgradeChannelHelper(channel).label;
}
export function getUpgradeChannelColour(channel: pond.UpgradeChannel): string {
return getUpgradeChannelHelper(channel).colour;
}

View file

@ -7,6 +7,7 @@ import { pondURL } from "./pond";
import { AxiosResponse } from "axios";
import { useGlobalState } from "providers";
import moment from "moment";
import { or } from "utils/types";
export interface IDeviceAPIContext {
add: (name: string, description: string, backpack: pond.BackpackSettings) => Promise<any>;
@ -55,7 +56,7 @@ export interface IDeviceAPIContext {
updatePermissions: (id: number | string, users: User[]) => Promise<any>;
updatePreferences: (
id: number | string,
preferences: pond.UserPreferences,
preferences: pond.DevicePreferences,
keys?: string[],
types?: string[]
) => Promise<any>;
@ -333,7 +334,7 @@ export default function DeviceProvider(props: PropsWithChildren<Props>) {
const updatePreferences = (
id: number | string,
preferences: pond.UserPreferences,
preferences: pond.DevicePreferences,
keys?: string[],
types?: string[]
) => {
@ -343,7 +344,7 @@ export default function DeviceProvider(props: PropsWithChildren<Props>) {
id +
"/preferences" +
"?as=" +
as +
or(as, "") +
(keys ? "&keys=" + keys.toString() : "") +
(types ? "&types=" + types.toString() : "")
),

88
src/utils/strings.ts Normal file
View file

@ -0,0 +1,88 @@
import {
red,
purple,
deepPurple,
indigo,
blue,
lightBlue,
cyan,
teal,
green,
lightGreen,
yellow,
amber,
orange,
deepOrange
} from "@mui/material/colors";
export function lowercaseStart(s: string): string {
if (s === "" || s.length <= 0) return "";
return s.charAt(0).toLowerCase() + s.substring(1);
}
export function capitalize(s: string): string {
return s.replace(/\b\w/g, c => c.toUpperCase());
}
/**
* Takes in a string and uses the first character of the frist word to determin colour and the first character of the second to determin shade
* if a string with only one word is passed will use default shade, words past 2 are not used
* @param s any string
* @returns hex colour string
*/
export function stringToMaterialColour(s: string): string {
const colours = [
red,
purple,
deepPurple,
indigo,
blue,
lightBlue,
cyan,
teal,
green,
lightGreen,
yellow,
amber,
orange,
deepOrange
];
const shades = [
"50",
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
"900",
"A100",
"A200",
"A400",
"A700"
];
let strings = s.split(" ");
let colourVal = 0;
let shadeVal = 5;
if (strings[0]) {
let val = Number(strings[0].charCodeAt(0));
while (val > 13) {
val = val - 13;
}
colourVal = val;
}
if (strings[1]) {
let val = Number(strings[1].charCodeAt(0));
while (val > 13) {
val = val - 13;
}
shadeVal = val;
}
let colour: any = colours[colourVal];
let shade: any = shades[shadeVal];
return colour[shade];
}