fixes and improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user