redesign
This commit is contained in:
@@ -328,6 +328,7 @@ def send_email_message(
|
|||||||
|
|
||||||
attempts: list[dict[str, Any]] = []
|
attempts: list[dict[str, Any]] = []
|
||||||
last_error = ""
|
last_error = ""
|
||||||
|
fallback_from_address = smtp_username if smtp_username and smtp_username != from_address else None
|
||||||
for mode in _smtp_mode_candidates(settings):
|
for mode in _smtp_mode_candidates(settings):
|
||||||
meta = _smtp_attempt_metadata(mode)
|
meta = _smtp_attempt_metadata(mode)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -341,40 +342,6 @@ def send_email_message(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_send_email_via_mode(mode, message, recipients, from_address)
|
_send_email_via_mode(mode, message, recipients, from_address)
|
||||||
attempts.append(
|
|
||||||
{
|
|
||||||
"label": meta["label"],
|
|
||||||
"smtp_host": meta["smtp_host"],
|
|
||||||
"smtp_port": meta["smtp_port"],
|
|
||||||
"use_tls": meta["use_tls"],
|
|
||||||
"use_ssl": meta["use_ssl"],
|
|
||||||
"status": "ok",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
|
|
||||||
meta["label"],
|
|
||||||
meta["smtp_host"],
|
|
||||||
meta["smtp_port"],
|
|
||||||
meta["transport"],
|
|
||||||
meta["auth_user"],
|
|
||||||
from_address,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"from_address": from_address,
|
|
||||||
"recipient_count": len(recipients),
|
|
||||||
"attachment_count": len(attachment_list),
|
|
||||||
"subject": subject,
|
|
||||||
"authenticated_as": smtp_username or None,
|
|
||||||
"selected_mode": {
|
|
||||||
"label": meta["label"],
|
|
||||||
"smtp_host": meta["smtp_host"],
|
|
||||||
"smtp_port": meta["smtp_port"],
|
|
||||||
"use_tls": meta["use_tls"],
|
|
||||||
"use_ssl": meta["use_ssl"],
|
|
||||||
},
|
|
||||||
"attempts": attempts,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = describe_smtp_error(exc)
|
last_error = describe_smtp_error(exc)
|
||||||
attempts.append(
|
attempts.append(
|
||||||
@@ -388,9 +355,9 @@ def send_email_message(
|
|||||||
"error": last_error,
|
"error": last_error,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if _smtp_sender_not_authorized(exc):
|
if _smtp_sender_not_authorized(exc) and fallback_from_address:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s; retrying with smtp_username",
|
||||||
meta["label"],
|
meta["label"],
|
||||||
meta["smtp_host"],
|
meta["smtp_host"],
|
||||||
meta["smtp_port"],
|
meta["smtp_port"],
|
||||||
@@ -399,16 +366,124 @@ def send_email_message(
|
|||||||
from_address,
|
from_address,
|
||||||
last_error,
|
last_error,
|
||||||
)
|
)
|
||||||
else:
|
fallback_message, fallback_from = build_email_message(
|
||||||
logger.warning(
|
settings,
|
||||||
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
recipients,
|
||||||
|
subject,
|
||||||
|
html_body,
|
||||||
|
text_body,
|
||||||
|
attachment_list,
|
||||||
|
sender_address=fallback_from_address,
|
||||||
|
reply_to_address=from_address,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_send_email_via_mode(mode, fallback_message, recipients, fallback_from)
|
||||||
|
except Exception as fallback_exc:
|
||||||
|
last_error = describe_smtp_error(fallback_exc)
|
||||||
|
attempts.append(
|
||||||
|
{
|
||||||
|
"label": meta["label"],
|
||||||
|
"smtp_host": meta["smtp_host"],
|
||||||
|
"smtp_port": meta["smtp_port"],
|
||||||
|
"use_tls": meta["use_tls"],
|
||||||
|
"use_ssl": meta["use_ssl"],
|
||||||
|
"status": "failed",
|
||||||
|
"error": last_error,
|
||||||
|
"sender_fallback": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"SMTP send fallback failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
||||||
|
meta["label"],
|
||||||
|
meta["smtp_host"],
|
||||||
|
meta["smtp_port"],
|
||||||
|
meta["transport"],
|
||||||
|
meta["auth_user"],
|
||||||
|
fallback_from,
|
||||||
|
last_error,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
attempts.append(
|
||||||
|
{
|
||||||
|
"label": meta["label"],
|
||||||
|
"smtp_host": meta["smtp_host"],
|
||||||
|
"smtp_port": meta["smtp_port"],
|
||||||
|
"use_tls": meta["use_tls"],
|
||||||
|
"use_ssl": meta["use_ssl"],
|
||||||
|
"status": "ok",
|
||||||
|
"sender_fallback": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"SMTP send succeeded via smtp_username label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
|
||||||
meta["label"],
|
meta["label"],
|
||||||
meta["smtp_host"],
|
meta["smtp_host"],
|
||||||
meta["smtp_port"],
|
meta["smtp_port"],
|
||||||
meta["transport"],
|
meta["transport"],
|
||||||
meta["auth_user"],
|
meta["auth_user"],
|
||||||
from_address,
|
fallback_from,
|
||||||
last_error,
|
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"from_address": fallback_from,
|
||||||
|
"recipient_count": len(recipients),
|
||||||
|
"attachment_count": len(attachment_list),
|
||||||
|
"subject": subject,
|
||||||
|
"authenticated_as": smtp_username or None,
|
||||||
|
"selected_mode": {
|
||||||
|
"label": meta["label"],
|
||||||
|
"smtp_host": meta["smtp_host"],
|
||||||
|
"smtp_port": meta["smtp_port"],
|
||||||
|
"use_tls": meta["use_tls"],
|
||||||
|
"use_ssl": meta["use_ssl"],
|
||||||
|
},
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
logger.warning(
|
||||||
|
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
||||||
|
meta["label"],
|
||||||
|
meta["smtp_host"],
|
||||||
|
meta["smtp_port"],
|
||||||
|
meta["transport"],
|
||||||
|
meta["auth_user"],
|
||||||
|
from_address,
|
||||||
|
last_error,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
attempts.append(
|
||||||
|
{
|
||||||
|
"label": meta["label"],
|
||||||
|
"smtp_host": meta["smtp_host"],
|
||||||
|
"smtp_port": meta["smtp_port"],
|
||||||
|
"use_tls": meta["use_tls"],
|
||||||
|
"use_ssl": meta["use_ssl"],
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
|
||||||
|
meta["label"],
|
||||||
|
meta["smtp_host"],
|
||||||
|
meta["smtp_port"],
|
||||||
|
meta["transport"],
|
||||||
|
meta["auth_user"],
|
||||||
|
from_address,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"from_address": from_address,
|
||||||
|
"recipient_count": len(recipients),
|
||||||
|
"attachment_count": len(attachment_list),
|
||||||
|
"subject": subject,
|
||||||
|
"authenticated_as": smtp_username or None,
|
||||||
|
"selected_mode": {
|
||||||
|
"label": meta["label"],
|
||||||
|
"smtp_host": meta["smtp_host"],
|
||||||
|
"smtp_port": meta["smtp_port"],
|
||||||
|
"use_tls": meta["use_tls"],
|
||||||
|
"use_ssl": meta["use_ssl"],
|
||||||
|
},
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
|
||||||
raise RuntimeError(last_error or "SMTP delivery failed")
|
raise RuntimeError(last_error or "SMTP delivery failed")
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class MailerTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["attempts"][1]["status"], "ok")
|
self.assertEqual(result["attempts"][1]["status"], "ok")
|
||||||
fallback_smtp.send_message.assert_called_once()
|
fallback_smtp.send_message.assert_called_once()
|
||||||
|
|
||||||
def test_send_email_message_rejects_unauthorized_from_address(self) -> None:
|
def test_send_email_message_retries_with_smtp_username_when_from_is_rejected(self) -> None:
|
||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
smtp_host="smtp.example.com",
|
smtp_host="smtp.example.com",
|
||||||
smtp_port=587,
|
smtp_port=587,
|
||||||
@@ -116,25 +116,30 @@ class MailerTests(unittest.TestCase):
|
|||||||
smtp_timeout=15,
|
smtp_timeout=15,
|
||||||
)
|
)
|
||||||
smtp = MagicMock()
|
smtp = MagicMock()
|
||||||
smtp.send_message.side_effect = smtplib.SMTPDataError(
|
smtp.send_message.side_effect = [
|
||||||
551, b"5.7.1 Not authorised to send from this header address"
|
smtplib.SMTPDataError(551, b"5.7.1 Not authorised to send from this header address"),
|
||||||
)
|
{},
|
||||||
|
]
|
||||||
smtp_factory = MagicMock(return_value=_SMTPContext(smtp))
|
smtp_factory = MagicMock(return_value=_SMTPContext(smtp))
|
||||||
|
|
||||||
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch(
|
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch(
|
||||||
"media_library_viewer_api.services.mailer.smtplib.SMTP_SSL"
|
"media_library_viewer_api.services.mailer.smtplib.SMTP_SSL"
|
||||||
) as smtp_ssl:
|
) as smtp_ssl:
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
result = send_email_message(
|
||||||
send_email_message(
|
settings,
|
||||||
settings,
|
recipients=["alex@example.com"],
|
||||||
recipients=["alex@example.com"],
|
subject="Hello",
|
||||||
subject="Hello",
|
html_body="<p>Hello</p>",
|
||||||
html_body="<p>Hello</p>",
|
)
|
||||||
)
|
|
||||||
|
|
||||||
smtp_ssl.assert_not_called()
|
smtp_ssl.assert_not_called()
|
||||||
self.assertIn("authorized alias", str(ctx.exception).lower())
|
self.assertEqual(result["from_address"], "mailer@example.com")
|
||||||
self.assertEqual(smtp.send_message.call_count, 1)
|
self.assertEqual(smtp.send_message.call_count, 2)
|
||||||
|
first_message = smtp.send_message.call_args_list[0].args[0]
|
||||||
|
second_message = smtp.send_message.call_args_list[1].args[0]
|
||||||
|
self.assertEqual(first_message["From"], "Manage <alias@example.com>")
|
||||||
|
self.assertEqual(second_message["From"], "Manage <mailer@example.com>")
|
||||||
|
self.assertEqual(second_message["Reply-To"], "alias@example.com")
|
||||||
|
|
||||||
def test_describe_smtp_error_handles_timeout(self) -> None:
|
def test_describe_smtp_error_handles_timeout(self) -> None:
|
||||||
detail = describe_smtp_error(TimeoutError("timed out"))
|
detail = describe_smtp_error(TimeoutError("timed out"))
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
- The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts.
|
- The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts.
|
||||||
- The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter.
|
- The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter.
|
||||||
- The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented.
|
- The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented.
|
||||||
|
- The frontend shell and primary pages should remain responsive and mobile-safe, with compact navigation, stacked controls on narrow screens, and reduced table column density where needed.
|
||||||
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
|
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
|
||||||
|
|
||||||
### Remote Filesystem over SSH
|
### Remote Filesystem over SSH
|
||||||
@@ -173,3 +174,4 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
- 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample.
|
- 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample.
|
||||||
- 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows.
|
- 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows.
|
||||||
- 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image.
|
- 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image.
|
||||||
|
- 2026-05-04: The frontend was adjusted to be more mobile-safe by making the app shell tabs scrollable, stacking header controls on narrow screens, and hiding low-priority table columns on smaller displays.
|
||||||
|
|||||||
+65
-27
@@ -49,20 +49,75 @@ function Shell({
|
|||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const current = location.pathname;
|
const current = location.pathname;
|
||||||
|
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<AppBar position="sticky" color="inherit" elevation={0}>
|
<AppBar position="sticky" color="inherit" elevation={0}>
|
||||||
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}>
|
<Toolbar
|
||||||
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}>
|
sx={{
|
||||||
Manage
|
display: "flex",
|
||||||
</Typography>
|
flexDirection: { xs: "column", md: "row" },
|
||||||
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
|
gap: 1,
|
||||||
|
py: { xs: 1, md: 0 },
|
||||||
|
minHeight: { xs: "auto", md: 68 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
width: "100%",
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1 }}>
|
||||||
|
Manage
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.75}
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authLabel && (
|
||||||
|
<Chip size="small" variant="outlined" label={authLabel} />
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={darkMode ? "Dark" : "Light"}
|
||||||
|
/>
|
||||||
|
{onSignOut && (
|
||||||
|
<Button size="small" variant="text" onClick={onSignOut}>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={current}
|
value={current}
|
||||||
textColor="primary"
|
textColor="primary"
|
||||||
indicatorColor="primary"
|
indicatorColor="primary"
|
||||||
sx={{ flex: 1 }}
|
variant={isMobile ? "scrollable" : "standard"}
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 40,
|
||||||
|
"& .MuiTab-root": {
|
||||||
|
minHeight: 40,
|
||||||
|
py: 1,
|
||||||
|
px: 1.25,
|
||||||
|
minWidth: { xs: 96, md: 120 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
|
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
|
||||||
<Tab
|
<Tab
|
||||||
@@ -73,31 +128,14 @@ function Shell({
|
|||||||
/>
|
/>
|
||||||
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
||||||
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
||||||
<Tab
|
<Tab value="/files" label="Files" component={NavLink} to="/files" />
|
||||||
value="/files"
|
|
||||||
label="File Browser"
|
|
||||||
component={NavLink}
|
|
||||||
to="/files"
|
|
||||||
/>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
{authLabel && (
|
|
||||||
<Chip size="small" variant="outlined" label={authLabel} />
|
|
||||||
)}
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={darkMode ? "Dark" : "Light"}
|
|
||||||
/>
|
|
||||||
{onSignOut && (
|
|
||||||
<Button size="small" variant="text" onClick={onSignOut}>
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Container maxWidth={false} sx={{ py: 3 }}>
|
<Container
|
||||||
|
maxWidth={false}
|
||||||
|
sx={{ py: { xs: 2, md: 3 }, px: { xs: 1.5, sm: 2.5, md: 3 } }}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/monitoring" element={<Monitoring />} />
|
<Route path="/monitoring" element={<Monitoring />} />
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ interface Props {
|
|||||||
|
|
||||||
export function MetricCard({ label, value, subtext }: Props) {
|
export function MetricCard({ label, value, subtext }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined">
|
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||||
<CardContent>
|
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
@@ -17,14 +17,22 @@ export function MetricCard({ label, value, subtext }: Props) {
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}>
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
||||||
|
lineHeight: 1.15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
{subtext && (
|
{subtext && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ whiteSpace: "pre-line" }}
|
sx={{ whiteSpace: "pre-line", display: "block", mt: 0.25 }}
|
||||||
>
|
>
|
||||||
{subtext}
|
{subtext}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function SessionActivityPanel({
|
|||||||
<TableContainer
|
<TableContainer
|
||||||
component={Paper}
|
component={Paper}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1 }}
|
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1, overflowX: "auto" }}
|
||||||
>
|
>
|
||||||
<Table size="small" stickyHeader aria-label="Session activity details">
|
<Table size="small" stickyHeader aria-label="Session activity details">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -198,7 +198,7 @@ export function SessionActivityPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
<TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
noWrap
|
noWrap
|
||||||
@@ -210,12 +210,12 @@ export function SessionActivityPanel({
|
|||||||
{session.type || "—"}
|
{session.type || "—"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
<TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
|
||||||
<Typography variant="body2" noWrap>
|
<Typography variant="body2" noWrap>
|
||||||
{session.device || "Unknown device"}
|
{session.device || "Unknown device"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap", display: { xs: "none", md: "table-cell" } }}>
|
||||||
<Typography variant="body2" noWrap>
|
<Typography variant="body2" noWrap>
|
||||||
{session.transcoding === "yes"
|
{session.transcoding === "yes"
|
||||||
? session.transcoding_type
|
? session.transcoding_type
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ body,
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
useDirectoryListing,
|
useDirectoryListing,
|
||||||
@@ -538,6 +539,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
|
|
||||||
export function FileBrowser() {
|
export function FileBrowser() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||||
const initialRequestedPath = searchParams.get("path") ?? "/";
|
const initialRequestedPath = searchParams.get("path") ?? "/";
|
||||||
const initialSelectedPath =
|
const initialSelectedPath =
|
||||||
initialRequestedPath !== "/" &&
|
initialRequestedPath !== "/" &&
|
||||||
@@ -628,7 +630,7 @@ export function FileBrowser() {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Typography variant="h5">File Browser</Typography>
|
<Typography variant="h5">File Browser</Typography>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
@@ -637,10 +639,10 @@ export function FileBrowser() {
|
|||||||
onChange={(e) => setPathInput(e.target.value)}
|
onChange={(e) => setPathInput(e.target.value)}
|
||||||
onKeyDown={handlePathSubmit}
|
onKeyDown={handlePathSubmit}
|
||||||
/>
|
/>
|
||||||
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}>
|
<Button fullWidth={isMobile} variant="outlined" onClick={() => navigate(pathInput || "/")}>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outlined" onClick={() => refetch()}>
|
<Button fullWidth={isMobile} variant="outlined" onClick={() => refetch()}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -667,6 +669,7 @@ export function FileBrowser() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
rowSelectionModel={rowSelectionModel}
|
rowSelectionModel={rowSelectionModel}
|
||||||
|
columnVisibilityModel={isMobile ? { ext: false, modified: false } : undefined}
|
||||||
hideFooter
|
hideFooter
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiDataGrid-columnHeaders": {
|
"& .MuiDataGrid-columnHeaders": {
|
||||||
@@ -731,7 +734,7 @@ export function FileBrowser() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 8 }}>
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!selectedJob || runJob.isPending}
|
disabled={!selectedJob || runJob.isPending}
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useMediaQuery,
|
useMediaQuery as useMediaDataQuery,
|
||||||
useBuildIndex,
|
useBuildIndex,
|
||||||
useStopBuildIndex,
|
useStopBuildIndex,
|
||||||
useForceStopBuildIndex,
|
useForceStopBuildIndex,
|
||||||
@@ -40,6 +41,7 @@ function formatDuration(seconds: number | null | undefined): string {
|
|||||||
|
|
||||||
export function Media() {
|
export function Media() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||||
const { data: status } = useMediaStatus();
|
const { data: status } = useMediaStatus();
|
||||||
const buildIndex = useBuildIndex();
|
const buildIndex = useBuildIndex();
|
||||||
const stopBuildIndex = useStopBuildIndex();
|
const stopBuildIndex = useStopBuildIndex();
|
||||||
@@ -53,7 +55,7 @@ export function Media() {
|
|||||||
const [limit] = useState(100);
|
const [limit] = useState(100);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
const { data: queryResult, isLoading } = useMediaQuery({
|
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||||
types,
|
types,
|
||||||
search,
|
search,
|
||||||
hdr_filter: hdrFilter,
|
hdr_filter: hdrFilter,
|
||||||
@@ -365,6 +367,21 @@ export function Media() {
|
|||||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||||
}}
|
}}
|
||||||
pageSizeOptions={[100]}
|
pageSizeOptions={[100]}
|
||||||
|
columnVisibilityModel={
|
||||||
|
isMobile
|
||||||
|
? {
|
||||||
|
series: false,
|
||||||
|
season: false,
|
||||||
|
episode: false,
|
||||||
|
bitrate: false,
|
||||||
|
video: false,
|
||||||
|
resolution: false,
|
||||||
|
date_added: false,
|
||||||
|
library: false,
|
||||||
|
path: false,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
hideFooter
|
hideFooter
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiDataGrid-columnHeaders": {
|
"& .MuiDataGrid-columnHeaders": {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||||
@@ -62,6 +63,7 @@ export function UsersPage() {
|
|||||||
const { data: activity } = useActivity();
|
const { data: activity } = useActivity();
|
||||||
const queueStatusQuery = useUserMessageQueueStatus();
|
const queueStatusQuery = useUserMessageQueueStatus();
|
||||||
const sendUserMessage = useSendUserMessage();
|
const sendUserMessage = useSendUserMessage();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||||
@@ -684,6 +686,7 @@ export function UsersPage() {
|
|||||||
py: 1.25,
|
py: 1.25,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
display: { xs: "none", md: "table-cell" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -715,6 +718,7 @@ export function UsersPage() {
|
|||||||
py: 1.25,
|
py: 1.25,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
display: { xs: "none", md: "table-cell" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -736,6 +740,7 @@ export function UsersPage() {
|
|||||||
py: 1.25,
|
py: 1.25,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
display: { xs: "none", md: "table-cell" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
@@ -747,6 +752,7 @@ export function UsersPage() {
|
|||||||
py: 1.25,
|
py: 1.25,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
display: { xs: "none", md: "table-cell" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -911,7 +917,7 @@ export function UsersPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Dialog open={composeOpen} onClose={closeCompose} fullWidth maxWidth="md">
|
<Dialog open={composeOpen} onClose={closeCompose} fullWidth maxWidth="md" fullScreen={isMobile}>
|
||||||
<DialogTitle sx={{ pr: 6 }}>
|
<DialogTitle sx={{ pr: 6 }}>
|
||||||
Message selected users
|
Message selected users
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
Reference in New Issue
Block a user