fixes and improvements

This commit is contained in:
2026-05-07 12:34:16 +02:00
parent 2ecca84892
commit bba23165ab
14 changed files with 860 additions and 158 deletions
+26
View File
@@ -33,6 +33,8 @@ import type {
ResolvedPath,
ResetLocalDatabaseInput,
ResetLocalDatabaseResponse,
DashboardShortcut,
DashboardShortcutInput,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
@@ -165,6 +167,30 @@ export const fetchMonitoringPoller = () =>
get<MonitoringPollerStatus>("/api/monitoring/poller");
export const fetchMonitoringOverview = () =>
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
export const fetchDashboardShortcuts = () =>
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
fetch(
buildUrl(
shortcut.id
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
: "/api/dashboard/shortcuts",
),
{
method: shortcut.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(shortcut),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<DashboardShortcut>;
});
export const deleteDashboardShortcut = (shortcutId: string) =>
del<{ status: string }>(
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
);
export const fetchMonitoringStatus = (machineId?: string) =>
get<MonitoringStatus>(
"/api/monitoring/status",
@@ -50,7 +50,43 @@ function formatRate(bytes: number): string {
function formatTime(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
return new Date(epochSeconds * 1000).toLocaleString();
const date = new Date(epochSeconds * 1000);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
}
function formatReadableTime(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
const date = new Date(epochSeconds * 1000);
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatClockTime(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
const date = new Date(epochSeconds * 1000);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatUpdatedDetails(epochSeconds: number | null): [string, string] {
if (!epochSeconds) return ["-", "-"];
return [
formatReadableTime(epochSeconds),
`${formatClockTime(epochSeconds)} · ${formatAge(epochSeconds)}`,
];
}
function formatAge(epochSeconds: number | null): string {
@@ -111,40 +147,101 @@ function metricSortValue(
}
function metricCell(value: string, subtext?: string) {
const [minLine, maxLine] = (subtext || "").split("\n");
const min = minLine?.replace(/^Low\s+/, "").trim();
const max = maxLine?.replace(/^High\s+/, "").trim();
return (
<Stack
spacing={0.1}
sx={{
alignItems: "center",
justifyContent: "center",
width: "100%",
minWidth: 110,
height: "100%",
minHeight: 96,
textAlign: "center",
py: 0.4,
}}
>
<Typography
variant="body2"
<Box
sx={{
fontWeight: 700,
fontSize: "0.95rem",
lineHeight: 1.1,
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 1.5,
py: 1.15,
borderRadius: 1,
}}
>
{value}
</Typography>
{subtext ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
whiteSpace: "pre-line",
lineHeight: 1.0,
fontSize: "0.64rem",
opacity: 0.9,
}}
>
{subtext}
</Typography>
) : null}
<Stack spacing={0.1} sx={{ alignItems: "center" }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontSize: "0.62rem",
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
10m avg
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 900,
fontSize: "1.14rem",
lineHeight: 1,
textAlign: "center",
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</Typography>
</Stack>
</Box>
<Stack
spacing={0.25}
sx={{
width: "100%",
pt: 0.1,
}}
>
{min ? (
<Chip
size="small"
variant="outlined"
label={`Min ${min}`}
sx={{
width: "100%",
height: 18,
"& .MuiChip-label": {
px: 0.5,
py: 0,
fontSize: "0.6rem",
lineHeight: 1,
},
}}
/>
) : null}
{max ? (
<Chip
size="small"
variant="outlined"
label={`Max ${max}`}
sx={{
width: "100%",
height: 18,
"& .MuiChip-label": {
px: 0.5,
py: 0,
fontSize: "0.6rem",
lineHeight: 1,
},
}}
/>
) : null}
</Stack>
</Stack>
);
}
@@ -197,11 +294,7 @@ export function MonitoringOverviewTable({
size="small"
variant="outlined"
color={poller?.worker_running ? "success" : "default"}
label={
poller?.worker_running
? `Poller running · ${poller.interval_seconds}s`
: "Poller stopped"
}
label={poller?.worker_running ? "running" : "stopped"}
/>
<Chip
size="small"
@@ -230,12 +323,30 @@ export function MonitoringOverviewTable({
border: 1,
borderColor: "divider",
borderRadius: 1,
overflow: "hidden",
overflowX: "auto",
overflowY: "hidden",
}
: undefined
}
>
<Table size="small">
<Table
size="small"
sx={{
minWidth: 1280,
tableLayout: "fixed",
"& .MuiTableCell-root": {
px: 1.1,
py: 0.9,
verticalAlign: "middle",
},
"& .MuiTableHead .MuiTableCell-root": {
fontSize: "0.7rem",
fontWeight: 700,
lineHeight: 1.15,
whiteSpace: "nowrap",
},
}}
>
<TableHead>
<TableRow>
<TableCell
@@ -393,12 +504,6 @@ export function MonitoringOverviewTable({
sortedRows.map((row) => {
const machine = row.machine;
const status = row.status || row.status_error || "-";
const note =
row.metrics_error ||
row.disk_error ||
machine.notes ||
row.disk?.mount ||
"";
const cpu = formatSummary(
row.cpu_summary,
(value) => `${value.toFixed(1)}%`,
@@ -457,28 +562,28 @@ export function MonitoringOverviewTable({
label={status}
/>
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(cpu.value, cpu.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(iowait.value, iowait.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(mem.value, mem.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(netDown.value, netDown.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(netUp.value, netUp.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(diskRead.value, diskRead.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{metricCell(diskWrite.value, diskWrite.subtext)}
</TableCell>
<TableCell align="right">
<TableCell align="center">
{row.disk
? metricCell(
row.disk.used_pct,
@@ -487,22 +592,19 @@ export function MonitoringOverviewTable({
: "-"}
</TableCell>
<TableCell>
<Stack spacing={0.25}>
<Typography variant="caption" color="text.secondary">
{formatTime(row.latest_sample?.ts ?? null)}
</Typography>
{note && (
<Stack spacing={0.15} sx={{ alignItems: "center" }}>
{formatUpdatedDetails(
row.latest_sample?.ts ?? null,
).map((line) => (
<Typography
key={line}
variant="caption"
color={
row.metrics_error || row.disk_error
? "error.main"
: "text.secondary"
}
color="text.secondary"
sx={{ lineHeight: 1.1 }}
>
{note}
{line}
</Typography>
)}
))}
</Stack>
</TableCell>
</TableRow>
+34 -1
View File
@@ -1,10 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
deleteDashboardShortcut,
fetchActivity,
fetchCounts,
fetchDashboardShortcuts,
fetchLibraries,
fetchMonitoringOverview,
saveDashboardShortcut,
} from "../api/client";
import type { DashboardShortcutInput } from "../types";
export function useCounts(machineId?: string) {
return useQuery({
@@ -40,3 +44,32 @@ export function useMonitoringOverview() {
// Backward-compatible alias used by older code.
export const useNowPlaying = useActivity;
export function useDashboardShortcuts() {
return useQuery({
queryKey: ["dashboard", "shortcuts"],
queryFn: fetchDashboardShortcuts,
refetchInterval: 30_000,
});
}
export function useSaveDashboardShortcut() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shortcut: DashboardShortcutInput) =>
saveDashboardShortcut(shortcut),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard", "shortcuts"] });
},
});
}
export function useDeleteDashboardShortcut() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shortcutId: string) => deleteDashboardShortcut(shortcutId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard", "shortcuts"] });
},
});
}
+384 -2
View File
@@ -1,17 +1,391 @@
import { Stack } from "@mui/material";
import { useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
FormHelperText,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useActivity, useMonitoringOverview } from "../hooks/useDashboard";
import {
useActivity,
useDashboardShortcuts,
useDeleteDashboardShortcut,
useMonitoringOverview,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { NowPlaying } from "../components/NowPlaying";
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
import { SectionCard } from "../components/SectionCard";
import { DialogFooter } from "../components/DialogFooter";
function emptyShortcut(): DashboardShortcutInput {
return {
id: null,
label: "",
shortcut_type: "website",
enabled: true,
icon: "",
url: "",
task_id: "",
machine_id: "",
user_id: "",
notes: "",
};
}
function normalizeWebsiteUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function shortcutHref(shortcut: DashboardShortcut): string {
if (shortcut.shortcut_type === "website") {
return normalizeWebsiteUrl(shortcut.url);
}
if (shortcut.shortcut_type === "action") {
if (!shortcut.task_id) return "";
const params = new URLSearchParams({ task: shortcut.task_id });
if (shortcut.machine_id) params.set("machine_id", shortcut.machine_id);
return `/actions?${params.toString()}`;
}
if (!shortcut.user_id) return "";
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
}
function ShortcutDialog({
open,
draft,
onChange,
onClose,
onSave,
}: {
open: boolean;
draft: DashboardShortcutInput;
onChange: (shortcut: DashboardShortcutInput) => void;
onClose: () => void;
onSave: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
<DialogContent dividers>
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
<Grid container spacing={1}>
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
<TextField
fullWidth
size="small"
label="Label"
value={draft.label}
onChange={(e) => onChange({ ...draft, label: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
<TextField
fullWidth
size="small"
label="Icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
helperText="Emoji or glyph"
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
<FormControl fullWidth size="small">
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={draft.shortcut_type}
onChange={(e) =>
onChange({
...draft,
shortcut_type: e.target
.value as DashboardShortcutInput["shortcut_type"],
})
}
>
<MenuItem value="website">Website</MenuItem>
<MenuItem value="action">Saved action</MenuItem>
<MenuItem value="user">User</MenuItem>
</Select>
<FormHelperText>
Website opens a URL. Saved actions jump to a task. Users
deep-link.
</FormHelperText>
</FormControl>
</Grid>
</Grid>
{draft.shortcut_type === "website" ? (
<TextField
fullWidth
size="small"
label="Website URL"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
helperText="https:// is added if missing."
/>
) : draft.shortcut_type === "action" ? (
<Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Task ID"
value={draft.task_id}
onChange={(e) =>
onChange({ ...draft, task_id: e.target.value })
}
helperText="Saved action ID."
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Machine ID"
value={draft.machine_id}
onChange={(e) =>
onChange({ ...draft, machine_id: e.target.value })
}
helperText="Optional machine target."
/>
</Grid>
</Grid>
) : (
<TextField
fullWidth
size="small"
label="User ID"
value={draft.user_id}
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
helperText="Jellyfin user ID."
/>
)}
<TextField
fullWidth
size="small"
label="Notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
<FormControlLabel
control={
<Switch
checked={draft.enabled}
onChange={(e) =>
onChange({ ...draft, enabled: e.target.checked })
}
/>
}
label="Enabled"
/>
</Stack>
</DialogContent>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</Dialog>
);
}
function ShortcutCard({
shortcut,
onOpen,
onEdit,
onDelete,
}: {
shortcut: DashboardShortcut;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const href = shortcutHref(shortcut);
const subtitle =
shortcut.shortcut_type === "website"
? shortcut.url || "No URL configured"
: shortcut.shortcut_type === "action"
? [
shortcut.task_id || "task pending",
shortcut.machine_id
? `machine ${shortcut.machine_id}`
: "any machine",
].join(" · ")
: shortcut.user_id || "No user configured";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: 1.25 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }} noWrap>
{shortcut.label}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{subtitle}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{shortcut.icon ? (
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
bgcolor: "action.hover",
fontSize: 18,
}}
>
{shortcut.icon}
</Box>
) : null}
<Chip
size="small"
variant="outlined"
label={shortcut.shortcut_type}
/>
</Stack>
</Stack>
{shortcut.notes ? (
<Typography variant="caption" color="text.secondary">
{shortcut.notes}
</Typography>
) : null}
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="contained"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
Open
</Button>
<Button size="small" variant="outlined" onClick={onEdit}>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={onDelete}
>
Delete
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}
export function Dashboard() {
const navigate = useNavigate();
const { data: activity } = useActivity();
const { data: monitoringOverview } = useMonitoringOverview();
const { data: shortcuts = [] } = useDashboardShortcuts();
const saveShortcut = useSaveDashboardShortcut();
const deleteShortcut = useDeleteDashboardShortcut();
const [shortcutDialogOpen, setShortcutDialogOpen] = useState(false);
const [shortcutDraft, setShortcutDraft] = useState<DashboardShortcutInput>(
emptyShortcut(),
);
const openCreateShortcut = () => {
setShortcutDraft(emptyShortcut());
setShortcutDialogOpen(true);
};
const openEditShortcut = (shortcut: DashboardShortcut) => {
setShortcutDraft({
id: shortcut.id,
label: shortcut.label,
shortcut_type: shortcut.shortcut_type,
enabled: shortcut.enabled,
icon: shortcut.icon,
url: shortcut.url,
task_id: shortcut.task_id,
machine_id: shortcut.machine_id,
user_id: shortcut.user_id,
notes: shortcut.notes,
});
setShortcutDialogOpen(true);
};
const saveShortcutDraft = async () => {
await saveShortcut.mutateAsync(shortcutDraft);
setShortcutDialogOpen(false);
setShortcutDraft(emptyShortcut());
};
return (
<Stack spacing={2.25}>
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<Button variant="outlined" onClick={openCreateShortcut}>
Add shortcut
</Button>
}
>
{shortcuts.length ? (
<Grid container spacing={1.25}>
{shortcuts.map((shortcut) => (
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
<ShortcutCard
shortcut={shortcut}
onOpen={() => {
const href = shortcutHref(shortcut);
if (shortcut.shortcut_type === "website") {
window.open(href, "_blank", "noopener,noreferrer");
} else if (href) {
navigate(href);
}
}}
onEdit={() => openEditShortcut(shortcut)}
onDelete={() => deleteShortcut.mutate(shortcut.id)}
/>
</Grid>
))}
</Grid>
) : (
<Alert severity="info">
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
</Alert>
)}
</SectionCard>
<SectionCard
title="Jellyfin activity"
description="Live sessions and idle users from Jellyfin."
@@ -32,6 +406,14 @@ export function Dashboard() {
>
<MonitoringOverviewTable overview={monitoringOverview} embedded />
</SectionCard>
<ShortcutDialog
open={shortcutDialogOpen}
draft={shortcutDraft}
onChange={setShortcutDraft}
onClose={() => setShortcutDialogOpen(false)}
onSave={saveShortcutDraft}
/>
</Stack>
);
}
+1 -3
View File
@@ -63,9 +63,7 @@ export function Monitoring() {
Fleet overview
</Typography>
<Typography variant="caption" color="text.secondary">
{poller?.worker_running
? `Poller running · ${poller.interval_seconds}s`
: "Poller stopped"}
{poller?.worker_running ? "running" : "stopped"}
</Typography>
</Stack>
+19 -59
View File
@@ -78,13 +78,20 @@ function MachineEditor({
hint,
machine,
sshKeys,
onChange,
}: {
title: string;
hint?: string;
machine: MonitoringMachineInput;
sshKeys: SSHKey[];
onChange: (
draft:
| MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => void;
}) {
const [draft, setDraft] = useState(machine);
const draft = machine;
const setDraft = onChange;
const isLocal = draft.mode === "local";
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
const enabledServices = draft.services.length;
@@ -246,34 +253,6 @@ function MachineEditor({
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Key directory"
value={draft.key_directory}
onChange={(e) =>
setDraft((current) => ({
...current,
key_directory: e.target.value,
}))
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Key name"
value={draft.key_name}
onChange={(e) =>
setDraft((current) => ({
...current,
key_name: e.target.value,
}))
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
@@ -305,20 +284,6 @@ function MachineEditor({
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Path prefix"
value={draft.path_prefix}
onChange={(e) =>
setDraft((current) => ({
...current,
path_prefix: e.target.value,
}))
}
/>
</Grid>
{hasJellyfin && (
<>
<Grid size={{ xs: 12, md: 6 }}>
@@ -1049,15 +1014,15 @@ export function Settings() {
host: machine.host,
port: machine.port,
username: machine.username,
key_directory: machine.key_directory,
key_name: machine.key_name,
ssh_key_id: machine.ssh_key_id,
key_directory: "",
key_name: "",
path_prefix: "",
ssh_key_id: machine.ssh_key_id,
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
media_root: machine.media_root,
path_prefix: machine.path_prefix,
jellyfin_url: machine.jellyfin_url,
jellyfin_url: machine.jellyfin_url,
jellyfin_user_id: machine.jellyfin_user_id,
jellyfin_api_key: "",
jellyseerr_url: machine.jellyseerr_url,
@@ -1148,12 +1113,6 @@ export function Settings() {
value={selectedMachine.username}
disabled
/>
<TextField
size="small"
label="Path prefix"
value={selectedMachine.path_prefix}
disabled
/>
</>
) : (
<TextField
@@ -1181,15 +1140,15 @@ export function Settings() {
host: selectedMachine.host,
port: selectedMachine.port,
username: selectedMachine.username,
key_directory: selectedMachine.key_directory,
key_name: selectedMachine.key_name,
ssh_key_id: selectedMachine.ssh_key_id,
key_directory: "",
key_name: "",
path_prefix: "",
ssh_key_id: selectedMachine.ssh_key_id,
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
media_root: selectedMachine.media_root,
path_prefix: selectedMachine.path_prefix,
jellyfin_url: selectedMachine.jellyfin_url,
jellyfin_url: selectedMachine.jellyfin_url,
jellyfin_user_id:
selectedMachine.jellyfin_user_id,
jellyfin_api_key: "",
@@ -1247,6 +1206,7 @@ export function Settings() {
}
machine={machineDraft}
sshKeys={sshKeys}
onChange={setMachineDraft}
/>
</DialogContent>
<DialogFooter
+28
View File
@@ -458,3 +458,31 @@ export interface ResolvedPath {
original: string;
resolved: string;
}
export interface DashboardShortcut {
id: string;
label: string;
shortcut_type: "website" | "action" | "user";
enabled: boolean;
icon: string;
url: string;
task_id: string;
machine_id: string;
user_id: string;
notes: string;
created_at: number;
updated_at: number;
}
export interface DashboardShortcutInput {
id?: string | null;
label: string;
shortcut_type: "website" | "action" | "user";
enabled: boolean;
icon: string;
url: string;
task_id: string;
machine_id: string;
user_id: string;
notes: string;
}