feat: implement device management UI

Adds device list panel with 1s real-time polling, device cards showing
connection type/Android version/battery, wireless connect dialog, and
DeviceInfoPanel with static fields fetched once and live fields (RAM,
battery, thermal, storage, IP) pulsed every second. Introduces the
selectedDeviceId vs profiledDeviceId split in Zustand store, Profile
toggle per card, and auto-clear on device disconnect. All non-device
feature panels gate on useActiveDevice and render NoDeviceSelected when
no device is being profiled.
This commit is contained in:
achmad
2026-05-06 14:51:44 +07:00
parent 14935db63e
commit 8009fc6b8a
26 changed files with 5044 additions and 86 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+3719 -69
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -10,6 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/geist": "^5.2.8",
"@tanstack/react-query": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -17,7 +19,9 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.13"
},
"devDependencies": {
+1
View File
@@ -0,0 +1 @@
24a0431a2cfc303dfc93d3a01c2b5e52
+39 -6
View File
@@ -1,17 +1,50 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
import { Sidebar } from '@/components/layout/Sidebar'
import { NoDeviceSelected } from '@/components/layout/NoDeviceSelected'
import { DevicePanel } from '@/features/devices/components/DevicePanel'
import { useActiveDevice } from '@/hooks/use-active-device'
function App() {
function FeaturePlaceholder({ label }: { label: string }) {
const { isMonitoring } = useActiveDevice()
if (!isMonitoring) return <NoDeviceSelected />
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{label} coming soon
</div>
)
}
function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full dark">
<Sidebar />
<main className="flex-1 overflow-hidden">{children}</main>
</div>
)
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<div className="p-8 text-white">DroidScope</div>} />
</Routes>
<Layout>
<Routes>
<Route path="/" element={<DevicePanel />} />
<Route path="/logcat" element={<FeaturePlaceholder label="Logcat" />} />
<Route path="/network" element={<FeaturePlaceholder label="Network Inspector" />} />
<Route path="/storage" element={<FeaturePlaceholder label="Storage" />} />
<Route path="/files" element={<FeaturePlaceholder label="File Browser" />} />
<Route path="/runtime" element={<FeaturePlaceholder label="Runtime Config" />} />
<Route path="/settings" element={
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Settings coming soon
</div>
} />
</Routes>
</Layout>
</BrowserRouter>
</QueryClientProvider>
)
}
export default App
@@ -0,0 +1,15 @@
import { Radio } from 'lucide-react'
export function NoDeviceSelected() {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<Radio size={40} strokeWidth={1.25} />
<div className="text-center">
<p className="text-sm font-medium">No device being profiled</p>
<p className="text-xs mt-1">
Go to Devices, select a device, and press <span className="text-foreground font-medium">Profile</span> to start monitoring.
</p>
</div>
</div>
)
}
@@ -0,0 +1,56 @@
import { NavLink } from 'react-router-dom'
import {
MonitorSmartphone,
ScrollText,
Network,
Database,
FolderOpen,
Sliders,
Settings,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ to: '/', icon: MonitorSmartphone, label: 'Devices' },
{ to: '/logcat', icon: ScrollText, label: 'Logcat' },
{ to: '/network', icon: Network, label: 'Network' },
{ to: '/storage', icon: Database, label: 'Storage' },
{ to: '/files', icon: FolderOpen, label: 'Files' },
{ to: '/runtime', icon: Sliders, label: 'Runtime' },
]
export function Sidebar() {
return (
<aside className="w-14 flex flex-col items-center py-3 gap-1 border-r border-border bg-card shrink-0">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
title={label}
className={({ isActive }) =>
cn(
'flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent',
isActive && 'text-foreground bg-accent',
)
}
>
<Icon size={18} />
</NavLink>
))}
<NavLink
to="/settings"
title="Settings"
className={({ isActive }) =>
cn(
'flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent mt-auto',
isActive && 'text-foreground bg-accent',
)
}
>
<Settings size={18} />
</NavLink>
</aside>
)
}
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { Wifi } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface Props {
onConnect: (address: string) => Promise<void>
}
export function ConnectDialog({ onConnect }: Props) {
const [open, setOpen] = useState(false)
const [address, setAddress] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleConnect() {
if (!address.trim()) return
setLoading(true)
setError('')
try {
await onConnect(address.trim())
setOpen(false)
setAddress('')
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
return (
<>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setOpen(true)}>
<Wifi size={14} />
Connect wireless
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect wireless device</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-2">
<Input
placeholder="192.168.1.100:5555"
value={address}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAddress(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && handleConnect()}
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleConnect} disabled={loading || !address.trim()}>
{loading ? 'Connecting…' : 'Connect'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,121 @@
import { Battery, Cpu, MonitorSmartphone, Wifi, Usb, Radio } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Device } from '../hooks/use-devices'
interface Props {
device: Device
isSelected: boolean
isProfiled: boolean
onSelect: (id: string) => void
onProfile: (id: string | null) => void
onDisconnect: (id: string) => void
}
const statusColor: Record<string, string> = {
online: 'bg-green-500',
offline: 'bg-zinc-500',
unauthorized: 'bg-yellow-500',
recovery: 'bg-orange-500',
}
export function DeviceCard({ device, isSelected, isProfiled, onSelect, onProfile, onDisconnect }: Props) {
const isOnline = device.status === 'online'
function handleProfileToggle(e: React.MouseEvent) {
e.stopPropagation()
onProfile(isProfiled ? null : device.id)
}
function handleDisconnect(e: React.MouseEvent) {
e.stopPropagation()
onDisconnect(device.id)
}
return (
<div
onClick={() => isOnline && onSelect(device.id)}
className={[
'rounded-lg border p-3 transition-colors',
isOnline ? 'cursor-pointer hover:bg-accent' : 'opacity-60',
isSelected ? 'border-primary bg-accent' : 'border-border',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<MonitorSmartphone className="shrink-0 text-muted-foreground" size={18} />
<div className="min-w-0">
<p className="font-medium text-sm truncate">
{device.model || device.product || device.id}
</p>
<p className="text-xs text-muted-foreground font-mono truncate">{device.id}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className={`inline-block w-2 h-2 rounded-full ${statusColor[device.status] ?? 'bg-zinc-500'}`} />
<span className="text-xs text-muted-foreground capitalize">{device.status}</span>
</div>
</div>
{isOnline && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<Badge variant="secondary" className="gap-1 text-[10px]">
{device.connectionType === 'wifi' ? <Wifi size={9} /> : <Usb size={9} />}
{device.connectionType.toUpperCase()}
</Badge>
{device.androidVersion && (
<Badge variant="secondary" className="text-[10px]">Android {device.androidVersion}</Badge>
)}
{device.abi && (
<Badge variant="secondary" className="gap-1 text-[10px]">
<Cpu size={9} />
{device.abi}
</Badge>
)}
{device.batteryLevel > 0 && (
<Badge variant="secondary" className="gap-1 text-[10px]">
<Battery size={9} />
{device.batteryLevel}%
</Badge>
)}
</div>
)}
{device.status === 'unauthorized' && (
<p className="mt-1.5 text-xs text-yellow-400">
Accept the USB debugging prompt on device.
</p>
)}
{isOnline && (
<div className="mt-2.5 flex items-center justify-between gap-2">
{/* Profile toggle */}
<button
onClick={handleProfileToggle}
className={[
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors',
isProfiled
? 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
].join(' ')}
>
<Radio size={11} className={isProfiled ? 'animate-pulse' : ''} />
{isProfiled ? 'Profiling' : 'Profile'}
</button>
{device.connectionType === 'wifi' && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
onClick={handleDisconnect}
>
Disconnect
</Button>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,238 @@
import {
Cpu,
HardDrive,
Battery,
Wifi,
Thermometer,
Smartphone,
Shield,
Monitor,
MemoryStick,
RefreshCw,
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useDeviceInfo } from '../hooks/use-device-info'
import { useDeviceLiveStats } from '../hooks/use-device-live-stats'
interface Props {
deviceId: string
}
const thermalColor: Record<string, string> = {
none: 'text-green-400',
light: 'text-yellow-400',
moderate: 'text-orange-400',
severe: 'text-red-400',
critical: 'text-red-500',
emergency: 'text-red-600',
shutdown: 'text-red-700',
}
export function DeviceInfoPanel({ deviceId }: Props) {
const { info, loading, error } = useDeviceInfo(deviceId)
const live = useDeviceLiveStats(deviceId)
if (loading) {
return (
<div className="flex items-center justify-center h-full gap-2 text-muted-foreground text-sm">
<RefreshCw size={14} className="animate-spin" />
Loading device info
</div>
)
}
if (error || !info) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{error ?? 'No device info available.'}
</div>
)
}
const usedRam = live && info.totalRamMb > 0
? info.totalRamMb - live.availableRamMb
: null
const ramPct = usedRam !== null && info.totalRamMb > 0
? Math.round((usedRam / info.totalRamMb) * 100)
: null
return (
<ScrollArea className="h-full">
<div className="p-5 space-y-5">
{/* Header */}
<div>
<h2 className="text-lg font-semibold">{info.model || info.product}</h2>
<p className="text-sm text-muted-foreground">{info.manufacturer}</p>
{info.serialNumber && (
<p className="text-xs text-muted-foreground font-mono mt-0.5">{info.serialNumber}</p>
)}
</div>
<Separator />
{/* System — static */}
<Section icon={Smartphone} title="System">
<Row label="Android" value={
info.androidVersion
? `Android ${info.androidVersion} (SDK ${info.sdkVersion})`
: '—'
} />
<Row label="Security patch" value={info.securityPatch || '—'} />
</Section>
<Separator />
{/* Processor — static */}
<Section icon={Cpu} title="Processor">
<Row label="Primary ABI" value={info.abi || '—'} />
<Row label="Supported ABIs" value={info.supportedAbis || '—'} />
</Section>
<Separator />
{/* Display — static */}
<Section icon={Monitor} title="Display">
<Row label="Resolution" value={info.screenResolution || '—'} />
<Row label="Density" value={info.screenDensity ? `${info.screenDensity} dpi` : '—'} />
</Section>
<Separator />
{/* Memory — total static, available live */}
<Section icon={MemoryStick} title="Memory">
<Row label="Total RAM" value={info.totalRamMb > 0 ? `${info.totalRamMb} MB` : '—'} />
<Row
label="Available RAM"
value={live ? `${live.availableRamMb} MB` : '—'}
live
/>
{ramPct !== null && (
<>
<div className="mt-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${ramPct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">{ramPct}% used</p>
</>
)}
</Section>
<Separator />
{/* Storage — total static, available live */}
<Section icon={HardDrive} title="Storage">
<Row label="Total" value={info.totalStorageGb || '—'} />
<Row label="Available" value={live?.availableStorageGb || '—'} live />
</Section>
<Separator />
{/* Battery — fully live */}
<Section icon={Battery} title="Battery">
<Row
label="Level"
value={
live && live.batteryLevel > 0 ? (
<span className={live.batteryLevel < 20 ? 'text-red-400' : ''}>
{live.batteryLevel}%
</span>
) : '—'
}
live
/>
<Row
label="Status"
value={live?.batteryStatus
? <Badge variant="secondary">{live.batteryStatus}</Badge>
: '—'
}
live
/>
</Section>
<Separator />
{/* Thermal — fully live */}
<Section icon={Thermometer} title="Thermal">
<Row
label="Status"
value={
live?.thermalStatus ? (
<span className={thermalColor[live.thermalStatus] ?? ''}>
{live.thermalStatus}
</span>
) : '—'
}
live
/>
</Section>
<Separator />
{/* Network — IP is live */}
<Section icon={Wifi} title="Network">
<Row label="IP address" value={live?.ipAddress || '—'} mono live />
</Section>
<Separator />
{/* Build — static */}
<Section icon={Shield} title="Build">
<Row label="Fingerprint" value={info.buildFingerprint || '—'} mono />
</Section>
</div>
</ScrollArea>
)
}
function Section({
icon: Icon,
title,
children,
}: {
icon: React.ElementType
title: string
children: React.ReactNode
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<Icon size={12} />
{title}
</div>
<div className="space-y-1.5">{children}</div>
</div>
)
}
function Row({
label,
value,
mono,
live,
}: {
label: string
value: React.ReactNode
mono?: boolean
live?: boolean
}) {
return (
<div className="flex items-start justify-between gap-4 text-sm">
<span className="text-muted-foreground shrink-0 flex items-center gap-1">
{live && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
)}
{label}
</span>
<span className={['text-right break-all', mono ? 'font-mono text-xs' : ''].join(' ')}>
{value ?? '—'}
</span>
</div>
)
}
@@ -0,0 +1,114 @@
import { useEffect } from 'react'
import { RefreshCw, MonitorSmartphone, Radio } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useDevices } from '../hooks/use-devices'
import { DeviceCard } from './DeviceCard'
import { ConnectDialog } from './ConnectDialog'
import { DeviceInfoPanel } from './DeviceInfoPanel'
import { useAppStore } from '@/store/app-store'
export function DevicePanel() {
const { devices, loading, connect, disconnect } = useDevices()
const {
selectedDeviceId, setSelectedDeviceId,
profiledDeviceId, setProfiledDeviceId,
} = useAppStore()
// Clear selected/profiled IDs when a device disconnects or goes offline
useEffect(() => {
const isOnline = (id: string | null) =>
id !== null && devices.some((d) => d.id === id && d.status === 'online')
if (!isOnline(selectedDeviceId)) setSelectedDeviceId(null)
if (!isOnline(profiledDeviceId)) setProfiledDeviceId(null)
}, [devices])
const profiledDevice = devices.find((d) => d.id === profiledDeviceId)
return (
<div className="flex h-full">
{/* Device list */}
<div className="w-96 flex flex-col border-r border-border shrink-0">
{/* Active profiling indicator */}
{profiledDevice ? (
<div className="flex items-center gap-2 px-4 py-2 bg-green-500/10 border-b border-green-500/20 shrink-0">
<Radio size={12} className="text-green-400 animate-pulse shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-green-400 truncate">
Profiling: {profiledDevice.model || profiledDevice.id}
</p>
</div>
<button
onClick={() => setProfiledDeviceId(null)}
className="text-xs text-green-400/70 hover:text-green-400 transition-colors shrink-0"
>
Stop
</button>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-2 bg-muted/30 border-b border-border shrink-0">
<Radio size={12} className="text-muted-foreground shrink-0" />
<p className="text-xs text-muted-foreground">No device being profiled</p>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div>
<h2 className="font-semibold text-sm">Devices</h2>
<p className="text-xs text-muted-foreground">
{devices.length} {devices.length === 1 ? 'device' : 'devices'} detected
</p>
</div>
<div className="flex items-center gap-2">
<ConnectDialog onConnect={connect} />
{loading && <RefreshCw size={14} className="animate-spin text-muted-foreground" />}
</div>
</div>
{/* List */}
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{devices.length === 0 && !loading ? (
<div className="text-center py-10 text-muted-foreground">
<p className="text-sm">No devices connected.</p>
<p className="text-xs mt-1">Connect via USB or use wireless ADB.</p>
</div>
) : (
devices.map((d) => (
<DeviceCard
key={d.id}
device={d}
isSelected={d.id === selectedDeviceId}
isProfiled={d.id === profiledDeviceId}
onSelect={setSelectedDeviceId}
onProfile={setProfiledDeviceId}
onDisconnect={disconnect}
/>
))
)}
</div>
</ScrollArea>
</div>
<Separator orientation="vertical" />
{/* Device info */}
<div className="flex-1 overflow-hidden">
{selectedDeviceId ? (
<DeviceInfoPanel deviceId={selectedDeviceId} />
) : (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<MonitorSmartphone size={40} strokeWidth={1.25} />
<div className="text-center">
<p className="text-sm font-medium">No device selected</p>
<p className="text-xs mt-1">Click a device to view its details.</p>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { GetDeviceInfo } from '@/wailsjs/go/main/App'
import type { device } from '@/wailsjs/go/models'
export type DeviceInfo = device.DeviceInfo
export function useDeviceInfo(deviceId: string | null) {
const [info, setInfo] = useState<DeviceInfo | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!deviceId) {
setInfo(null)
return
}
setLoading(true)
setError(null)
GetDeviceInfo(deviceId)
.then(setInfo)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [deviceId])
return { info, loading, error }
}
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from 'react'
import { GetDeviceLiveStats } from '@/wailsjs/go/main/App'
import type { device } from '@/wailsjs/go/models'
export type DeviceLiveStats = device.DeviceLiveStats
export function useDeviceLiveStats(deviceId: string | null) {
const [stats, setStats] = useState<DeviceLiveStats | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!deviceId) {
setStats(null)
return
}
// Fetch immediately, then every second
GetDeviceLiveStats(deviceId).then(setStats).catch(() => {})
intervalRef.current = setInterval(() => {
GetDeviceLiveStats(deviceId).then(setStats).catch(() => {})
}, 1000)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [deviceId])
return stats
}
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import { ListDevices, ConnectDevice, DisconnectDevice } from '@/wailsjs/go/main/App'
import { EventsOn, EventsOff } from '@/wailsjs/runtime/runtime'
import type { device } from '@/wailsjs/go/models'
export type Device = device.Device
export function useDevices() {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
ListDevices()
.then((d) => setDevices(d ?? []))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
EventsOn('devices:changed', (d: Device[]) => setDevices(d ?? []))
return () => EventsOff('devices:changed')
}, [])
async function connect(address: string) {
await ConnectDevice(address)
}
async function disconnect(deviceId: string) {
await DisconnectDevice(deviceId)
}
return { devices, loading, error, connect, disconnect }
}
@@ -0,0 +1,20 @@
import { useAppStore } from '@/store/app-store'
/**
* Returns the profiled device ID — the device all feature panels monitor.
* `isMonitoring` is false when no device is being profiled.
*
* This is separate from `selectedDeviceId` (info panel view).
* Data in `deviceCache` is never cleared on device switch.
*/
export function useActiveDevice() {
const { profiledDeviceId, deviceCache, patchDeviceCache, clearDeviceCache } = useAppStore()
return {
activeDeviceId: profiledDeviceId,
isMonitoring: profiledDeviceId !== null,
deviceCache,
patchDeviceCache,
clearDeviceCache,
}
}
+129
View File
@@ -1,4 +1,9 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
* {
box-sizing: border-box;
@@ -18,3 +23,127 @@ body {
display: flex;
flex-direction: column;
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
+36 -4
View File
@@ -1,11 +1,43 @@
import { create } from 'zustand'
export interface DeviceCache {
logcat?: unknown[]
networkRequests?: unknown[]
}
interface AppState {
activeDeviceId: string | null
setActiveDeviceId: (id: string | null) => void
/** Device whose info is shown in the Devices panel. */
selectedDeviceId: string | null
setSelectedDeviceId: (id: string | null) => void
/** Device being actively monitored by all feature panels (Logcat, Network, etc.). */
profiledDeviceId: string | null
setProfiledDeviceId: (id: string | null) => void
deviceCache: Record<string, DeviceCache>
patchDeviceCache: (deviceId: string, patch: Partial<DeviceCache>) => void
clearDeviceCache: (deviceId: string) => void
}
export const useAppStore = create<AppState>((set) => ({
activeDeviceId: null,
setActiveDeviceId: (id) => set({ activeDeviceId: id }),
selectedDeviceId: null,
setSelectedDeviceId: (id) => set({ selectedDeviceId: id }),
profiledDeviceId: null,
setProfiledDeviceId: (id) => set({ profiledDeviceId: id }),
deviceCache: {},
patchDeviceCache: (deviceId, patch) =>
set((s) => ({
deviceCache: {
...s.deviceCache,
[deviceId]: { ...s.deviceCache[deviceId], ...patch },
},
})),
clearDeviceCache: (deviceId) =>
set((s) => {
const next = { ...s.deviceCache }
delete next[deviceId]
return { deviceCache: next }
}),
}))
+3 -4
View File
@@ -16,11 +16,10 @@
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
-1
View File
@@ -10,7 +10,6 @@ export default defineConfig({
},
server: {
port: 5173,
strictPort: true,
},
build: {
outDir: '../desktop/frontend/dist',