mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 14:41:45 +08:00
feat: 支持 WatchDog
This commit is contained in:
@@ -10,6 +10,7 @@ mod computer_use;
|
|||||||
mod hid;
|
mod hid;
|
||||||
mod otg_network;
|
mod otg_network;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
mod watchdog;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
pub use atx::*;
|
pub use atx::*;
|
||||||
@@ -18,6 +19,7 @@ pub use computer_use::*;
|
|||||||
pub use hid::*;
|
pub use hid::*;
|
||||||
pub use otg_network::*;
|
pub use otg_network::*;
|
||||||
pub use stream::*;
|
pub use stream::*;
|
||||||
|
pub use watchdog::*;
|
||||||
pub use web::*;
|
pub use web::*;
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
@@ -41,6 +43,7 @@ pub struct AppConfig {
|
|||||||
pub vnc: VncConfig,
|
pub vnc: VncConfig,
|
||||||
pub rtsp: RtspConfig,
|
pub rtsp: RtspConfig,
|
||||||
pub redfish: RedfishConfig,
|
pub redfish: RedfishConfig,
|
||||||
|
pub watchdog: WatchdogConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -57,3 +60,18 @@ impl AppConfig {
|
|||||||
self.enforce_invariants();
|
self.enforce_invariants();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_watchdog_config_defaults_to_disabled() {
|
||||||
|
let value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||||
|
let mut object = value.as_object().unwrap().clone();
|
||||||
|
object.remove("watchdog");
|
||||||
|
|
||||||
|
let config: AppConfig = serde_json::from_value(object.into()).unwrap();
|
||||||
|
assert!(!config.watchdog.enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
9
src/config/schema/watchdog.rs
Normal file
9
src/config/schema/watchdog.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct WatchdogConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
@@ -154,4 +154,24 @@ mod tests {
|
|||||||
assert!(config.initialized);
|
assert!(config.initialized);
|
||||||
assert_eq!(config.web.http_port, 9000);
|
assert_eq!(config.web.http_port, 9000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_watchdog_persistence_does_not_update_cache() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("test.db");
|
||||||
|
let db = DatabasePool::new(&db_path).await.unwrap();
|
||||||
|
db.init_schema().await.unwrap();
|
||||||
|
let store = ConfigStore::new(db.clone_pool()).unwrap();
|
||||||
|
store.load().await.unwrap();
|
||||||
|
|
||||||
|
sqlx::query("DROP TABLE config")
|
||||||
|
.execute(&db.clone_pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store
|
||||||
|
.update(|config| config.watchdog.enabled = true)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(!store.get().watchdog.enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ pub mod video;
|
|||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod vnc;
|
pub mod vnc;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
|
pub mod watchdog;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
pub mod web;
|
pub mod web;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod webrtc;
|
pub mod webrtc;
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -591,6 +591,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
data_dir.clone(),
|
data_dir.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if config.watchdog.enabled {
|
||||||
|
if let Err(error) = state.watchdog.enable().await {
|
||||||
|
tracing::error!(
|
||||||
|
"Configured hardware watchdog failed to start; web service will continue: {}",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Hardware watchdog started");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extensions.set_event_bus(events.clone()).await;
|
extensions.set_event_bus(events.clone()).await;
|
||||||
|
|
||||||
if let Some(ref service) = rustdesk {
|
if let Some(ref service) = rustdesk {
|
||||||
@@ -1249,4 +1260,11 @@ async fn cleanup(state: &Arc<AppState>) {
|
|||||||
if let Err(e) = state.audio.shutdown().await {
|
if let Err(e) = state.audio.shutdown().await {
|
||||||
tracing::warn!("Failed to shutdown audio: {}", e);
|
tracing::warn!("Failed to shutdown audio: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(error) = state.watchdog.disable().await {
|
||||||
|
tracing::error!(
|
||||||
|
"CRITICAL: failed to disable hardware watchdog during shutdown: {}",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::rustdesk::RustDeskService;
|
|||||||
use crate::update::UpdateService;
|
use crate::update::UpdateService;
|
||||||
use crate::video::VideoStreamManager;
|
use crate::video::VideoStreamManager;
|
||||||
use crate::vnc::VncService;
|
use crate::vnc::VncService;
|
||||||
|
use crate::watchdog::WatchdogController;
|
||||||
use crate::webrtc::WebRtcStreamer;
|
use crate::webrtc::WebRtcStreamer;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -34,6 +35,7 @@ pub struct ConfigApplyLocks {
|
|||||||
pub rustdesk: Arc<Mutex<()>>,
|
pub rustdesk: Arc<Mutex<()>>,
|
||||||
pub vnc: Arc<Mutex<()>>,
|
pub vnc: Arc<Mutex<()>>,
|
||||||
pub rtsp: Arc<Mutex<()>>,
|
pub rtsp: Arc<Mutex<()>>,
|
||||||
|
pub watchdog: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -53,6 +55,7 @@ impl ConfigApplyLocks {
|
|||||||
rustdesk: Arc::new(Mutex::new(())),
|
rustdesk: Arc::new(Mutex::new(())),
|
||||||
vnc: Arc::new(Mutex::new(())),
|
vnc: Arc::new(Mutex::new(())),
|
||||||
rtsp: Arc::new(Mutex::new(())),
|
rtsp: Arc::new(Mutex::new(())),
|
||||||
|
watchdog: Arc::new(Mutex::new(())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,7 @@ pub struct AppState {
|
|||||||
pub events: Arc<EventBus>,
|
pub events: Arc<EventBus>,
|
||||||
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||||
pub update: Arc<UpdateService>,
|
pub update: Arc<UpdateService>,
|
||||||
|
pub watchdog: Arc<WatchdogController>,
|
||||||
pub shutdown_tx: broadcast::Sender<ShutdownAction>,
|
pub shutdown_tx: broadcast::Sender<ShutdownAction>,
|
||||||
pub revoked_sessions: Arc<RwLock<VecDeque<String>>>,
|
pub revoked_sessions: Arc<RwLock<VecDeque<String>>>,
|
||||||
pub config_apply_locks: ConfigApplyLocks,
|
pub config_apply_locks: ConfigApplyLocks,
|
||||||
@@ -134,6 +138,7 @@ impl AppState {
|
|||||||
events,
|
events,
|
||||||
device_info_tx,
|
device_info_tx,
|
||||||
update,
|
update,
|
||||||
|
watchdog: Arc::new(WatchdogController::new()),
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||||
config_apply_locks: ConfigApplyLocks::new(),
|
config_apply_locks: ConfigApplyLocks::new(),
|
||||||
|
|||||||
761
src/watchdog/mod.rs
Normal file
761
src/watchdog/mod.rs
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod platform {
|
||||||
|
use super::{Backend, Device, DiscoveredWatchdog};
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const WDIOC_GETSUPPORT: libc::c_ulong = 0x8028_5700;
|
||||||
|
const WDIOC_SETOPTIONS: libc::c_ulong = 0x8004_5704;
|
||||||
|
const WDIOC_KEEPALIVE: libc::c_ulong = 0x8004_5705;
|
||||||
|
const WDIOC_GETTIMEOUT: libc::c_ulong = 0x8004_5707;
|
||||||
|
const WDIOS_DISABLECARD: libc::c_int = 0x0001;
|
||||||
|
const WDIOF_MAGICCLOSE: u32 = 0x0100;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct WatchdogInfo {
|
||||||
|
options: u32,
|
||||||
|
firmware_version: u32,
|
||||||
|
identity: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LinuxBackend {
|
||||||
|
sys_root: PathBuf,
|
||||||
|
dev_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LinuxBackend {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sys_root: PathBuf::from("/sys/class/watchdog"),
|
||||||
|
dev_root: PathBuf::from("/dev"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend for LinuxBackend {
|
||||||
|
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||||
|
discover_at(&self.sys_root, &self.dev_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self, path: &Path) -> io::Result<Box<dyn Device>> {
|
||||||
|
let file = OpenOptions::new().write(true).open(path)?;
|
||||||
|
let mut info = WatchdogInfo::default();
|
||||||
|
let supports_magic_close = unsafe {
|
||||||
|
libc::ioctl(
|
||||||
|
std::os::fd::AsRawFd::as_raw_fd(&file),
|
||||||
|
WDIOC_GETSUPPORT,
|
||||||
|
&mut info,
|
||||||
|
) == 0
|
||||||
|
&& info.options & WDIOF_MAGICCLOSE != 0
|
||||||
|
};
|
||||||
|
Ok(Box::new(LinuxDevice {
|
||||||
|
file,
|
||||||
|
supports_magic_close,
|
||||||
|
nowayout: self.device_nowayout(path),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxBackend {
|
||||||
|
fn device_nowayout(&self, path: &Path) -> Option<bool> {
|
||||||
|
let direct_index = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| watchdog_index(&name.to_string_lossy()));
|
||||||
|
if let Some(index) = direct_index {
|
||||||
|
return read_trimmed(&self.sys_root.join(format!("watchdog{index}/nowayout")))
|
||||||
|
.and_then(|value| parse_boolean_flag(&value));
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_at(&self.sys_root, &self.dev_root)
|
||||||
|
.ok()
|
||||||
|
.and_then(|devices| {
|
||||||
|
devices.into_iter().find(|device| {
|
||||||
|
device
|
||||||
|
.paths
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| same_file(candidate, path))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(|device| {
|
||||||
|
read_trimmed(
|
||||||
|
&self
|
||||||
|
.sys_root
|
||||||
|
.join(format!("watchdog{}/nowayout", device.index)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.and_then(|value| parse_boolean_flag(&value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LinuxDevice {
|
||||||
|
file: File,
|
||||||
|
supports_magic_close: bool,
|
||||||
|
nowayout: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device for LinuxDevice {
|
||||||
|
fn keep_alive(&mut self) -> io::Result<()> {
|
||||||
|
let result = unsafe {
|
||||||
|
libc::ioctl(
|
||||||
|
std::os::fd::AsRawFd::as_raw_fd(&self.file),
|
||||||
|
WDIOC_KEEPALIVE,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timeout(&mut self) -> io::Result<u32> {
|
||||||
|
let mut timeout: libc::c_int = 0;
|
||||||
|
let result = unsafe {
|
||||||
|
libc::ioctl(
|
||||||
|
std::os::fd::AsRawFd::as_raw_fd(&self.file),
|
||||||
|
WDIOC_GETTIMEOUT,
|
||||||
|
&mut timeout,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result == 0 && timeout > 0 {
|
||||||
|
Ok(timeout as u32)
|
||||||
|
} else if result == 0 {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"watchdog reported a zero timeout",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable(&mut self) -> io::Result<()> {
|
||||||
|
if self.nowayout == Some(true) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"watchdog nowayout is enabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut option = WDIOS_DISABLECARD;
|
||||||
|
let result = unsafe {
|
||||||
|
libc::ioctl(
|
||||||
|
std::os::fd::AsRawFd::as_raw_fd(&self.file),
|
||||||
|
WDIOC_SETOPTIONS,
|
||||||
|
&mut option,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ioctl_error = io::Error::last_os_error();
|
||||||
|
if self.supports_magic_close && self.nowayout == Some(false) {
|
||||||
|
self.file.write_all(b"V")?;
|
||||||
|
self.file.flush()
|
||||||
|
} else if self.supports_magic_close {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"watchdog nowayout state cannot be verified",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(ioctl_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watchdog_index(name: &str) -> Option<u32> {
|
||||||
|
name.strip_prefix("watchdog")?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_boolean_flag(value: &str) -> Option<bool> {
|
||||||
|
match value {
|
||||||
|
"0" => Some(false),
|
||||||
|
"1" => Some(true),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_trimmed(path: &Path) -> Option<String> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_marker(path: &Path) -> Option<String> {
|
||||||
|
fs::canonicalize(path)
|
||||||
|
.ok()
|
||||||
|
.or_else(|| fs::read_link(path).ok())
|
||||||
|
.and_then(|path| {
|
||||||
|
path.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_softdog(entry: &Path) -> bool {
|
||||||
|
let mut markers = Vec::new();
|
||||||
|
for name in ["identity", "name"] {
|
||||||
|
if let Some(value) = read_trimmed(&entry.join(name)) {
|
||||||
|
markers.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in [
|
||||||
|
entry.join("device/driver"),
|
||||||
|
entry.join("device/driver/module"),
|
||||||
|
] {
|
||||||
|
if let Some(value) = path_marker(&path) {
|
||||||
|
markers.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markers.into_iter().any(|value| {
|
||||||
|
let value = value.to_ascii_lowercase();
|
||||||
|
value.contains("softdog") || value.contains("software watchdog")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_file(left: &Path, right: &Path) -> bool {
|
||||||
|
if let (Ok(left), Ok(right)) = (fs::canonicalize(left), fs::canonicalize(right)) {
|
||||||
|
if left == right {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (fs::metadata(left), fs::metadata(right)) {
|
||||||
|
(Ok(left), Ok(right)) => {
|
||||||
|
if left.file_type().is_char_device() && right.file_type().is_char_device() {
|
||||||
|
left.rdev() == right.rdev()
|
||||||
|
} else {
|
||||||
|
left.dev() == right.dev() && left.ino() == right.ino()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alias_matches_sysfs(entry: &Path, alias: &Path) -> bool {
|
||||||
|
let Some(dev) = read_trimmed(&entry.join("dev")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((major, minor)) = dev.split_once(':') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let (Ok(major), Ok(minor), Ok(metadata)) = (
|
||||||
|
major.parse::<u32>(),
|
||||||
|
minor.parse::<u32>(),
|
||||||
|
fs::metadata(alias),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
metadata.file_type().is_char_device()
|
||||||
|
&& libc::major(metadata.rdev()) == major
|
||||||
|
&& libc::minor(metadata.rdev()) == minor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn discover_at(
|
||||||
|
sys_root: &Path,
|
||||||
|
dev_root: &Path,
|
||||||
|
) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||||
|
let entries = match fs::read_dir(sys_root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let alias = dev_root.join("watchdog");
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
let Some(index) = watchdog_index(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_softdog(&entry.path()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let numbered = dev_root.join(name.as_ref());
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
if numbered.exists() {
|
||||||
|
paths.push(numbered.clone());
|
||||||
|
}
|
||||||
|
if alias.exists()
|
||||||
|
&& (same_file(&numbered, &alias)
|
||||||
|
|| (!numbered.exists() && alias_matches_sysfs(&entry.path(), &alias)))
|
||||||
|
&& !paths.iter().any(|path| same_file(path, &alias))
|
||||||
|
{
|
||||||
|
paths.push(alias.clone());
|
||||||
|
}
|
||||||
|
if !paths.is_empty() {
|
||||||
|
devices.push(DiscoveredWatchdog { index, paths });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devices.sort_by_key(|device| device.index);
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn create_watchdog(sys: &Path, dev: &Path, index: u32, identity: &str) {
|
||||||
|
let entry = sys.join(format!("watchdog{index}"));
|
||||||
|
fs::create_dir_all(&entry).unwrap();
|
||||||
|
fs::write(entry.join("identity"), identity).unwrap();
|
||||||
|
File::create(dev.join(format!("watchdog{index}"))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_hardware_in_numeric_order_and_excludes_softdog() {
|
||||||
|
let temp = tempdir().unwrap();
|
||||||
|
let sys = temp.path().join("sys");
|
||||||
|
let dev = temp.path().join("dev");
|
||||||
|
fs::create_dir_all(&sys).unwrap();
|
||||||
|
fs::create_dir_all(&dev).unwrap();
|
||||||
|
create_watchdog(&sys, &dev, 12, "Hardware watchdog");
|
||||||
|
create_watchdog(&sys, &dev, 2, "Board WDT");
|
||||||
|
create_watchdog(&sys, &dev, 1, "Software Watchdog");
|
||||||
|
|
||||||
|
let found = discover_at(&sys, &dev).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
found.iter().map(|item| item.index).collect::<Vec<_>>(),
|
||||||
|
[2, 12]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_duplicate_matching_watchdog_alias() {
|
||||||
|
let temp = tempdir().unwrap();
|
||||||
|
let sys = temp.path().join("sys");
|
||||||
|
let dev = temp.path().join("dev");
|
||||||
|
fs::create_dir_all(&sys).unwrap();
|
||||||
|
fs::create_dir_all(&dev).unwrap();
|
||||||
|
create_watchdog(&sys, &dev, 0, "Board WDT");
|
||||||
|
symlink("watchdog0", dev.join("watchdog")).unwrap();
|
||||||
|
|
||||||
|
let found = discover_at(&sys, &dev).unwrap();
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].paths, vec![dev.join("watchdog0")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_sysfs_directory_means_unsupported() {
|
||||||
|
let temp = tempdir().unwrap();
|
||||||
|
let found = discover_at(&temp.path().join("missing"), temp.path()).unwrap();
|
||||||
|
assert!(found.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excludes_softdog_identified_by_driver() {
|
||||||
|
let temp = tempdir().unwrap();
|
||||||
|
let sys = temp.path().join("sys");
|
||||||
|
let dev = temp.path().join("dev");
|
||||||
|
fs::create_dir_all(&sys).unwrap();
|
||||||
|
fs::create_dir_all(&dev).unwrap();
|
||||||
|
create_watchdog(&sys, &dev, 0, "Watchdog");
|
||||||
|
let device = sys.join("watchdog0/device");
|
||||||
|
fs::create_dir_all(&device).unwrap();
|
||||||
|
symlink("/sys/bus/platform/drivers/softdog", device.join("driver")).unwrap();
|
||||||
|
|
||||||
|
assert!(discover_at(&sys, &dev).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use super::{Backend, Device, DiscoveredWatchdog};
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct UnsupportedBackend;
|
||||||
|
|
||||||
|
impl Backend for UnsupportedBackend {
|
||||||
|
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self, _path: &Path) -> io::Result<Box<dyn Device>> {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Unsupported,
|
||||||
|
"hardware watchdog is unsupported on Windows",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DiscoveredWatchdog {
|
||||||
|
index: u32,
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Device: Send {
|
||||||
|
fn keep_alive(&mut self) -> io::Result<()>;
|
||||||
|
fn timeout(&mut self) -> io::Result<u32>;
|
||||||
|
fn disable(&mut self) -> io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Backend: Send + Sync {
|
||||||
|
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>>;
|
||||||
|
fn open(&self, path: &std::path::Path) -> io::Result<Box<dyn Device>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WatchdogRuntimeStatus {
|
||||||
|
pub supported: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SharedState {
|
||||||
|
running: bool,
|
||||||
|
last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkerCommand {
|
||||||
|
Disable(oneshot::Sender<io::Result<()>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RunningWatchdog {
|
||||||
|
commands: mpsc::Sender<WorkerCommand>,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WatchdogController {
|
||||||
|
backend: Arc<dyn Backend>,
|
||||||
|
shared: Arc<StdMutex<SharedState>>,
|
||||||
|
running: Mutex<Option<RunningWatchdog>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WatchdogController {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchdogController {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let backend = Arc::new(platform::LinuxBackend::default());
|
||||||
|
#[cfg(windows)]
|
||||||
|
let backend = Arc::new(platform::UnsupportedBackend);
|
||||||
|
Self::with_backend(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_backend(backend: Arc<dyn Backend>) -> Self {
|
||||||
|
Self {
|
||||||
|
backend,
|
||||||
|
shared: Arc::new(StdMutex::new(SharedState::default())),
|
||||||
|
running: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enable(&self) -> io::Result<()> {
|
||||||
|
let mut running = self.running.lock().await;
|
||||||
|
if running.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = self.backend.discover().map_err(|error| {
|
||||||
|
self.record_error(format!("Failed to discover hardware watchdog: {error}"));
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
if devices.is_empty() {
|
||||||
|
let message = "No hardware watchdog device found";
|
||||||
|
self.record_error(message.to_string());
|
||||||
|
return Err(io::Error::new(io::ErrorKind::NotFound, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut open_errors = Vec::new();
|
||||||
|
let mut selected = None;
|
||||||
|
'devices: for device in devices {
|
||||||
|
for path in device.paths {
|
||||||
|
match self.backend.open(&path) {
|
||||||
|
Ok(mut handle) => {
|
||||||
|
let initial_error = handle
|
||||||
|
.keep_alive()
|
||||||
|
.err()
|
||||||
|
.map(|error| format!("Watchdog initial keepalive failed: {error}"));
|
||||||
|
selected = Some((path, handle, initial_error));
|
||||||
|
break 'devices;
|
||||||
|
}
|
||||||
|
Err(error) => open_errors.push(format!("{}: {error}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((path, mut device, initial_error)) = selected else {
|
||||||
|
let message = format!(
|
||||||
|
"Failed to open a hardware watchdog: {}",
|
||||||
|
open_errors.join("; ")
|
||||||
|
);
|
||||||
|
self.record_error(message.clone());
|
||||||
|
return Err(io::Error::new(io::ErrorKind::Other, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeout = device.timeout().unwrap_or(30);
|
||||||
|
let period = Duration::from_secs(u64::from((timeout / 3).max(1)));
|
||||||
|
let (commands, receiver) = mpsc::channel(1);
|
||||||
|
let shared = self.shared.clone();
|
||||||
|
{
|
||||||
|
let mut state = shared.lock().unwrap();
|
||||||
|
state.running = initial_error.is_none();
|
||||||
|
state.last_error = initial_error;
|
||||||
|
}
|
||||||
|
let task = tokio::spawn(run_worker(device, receiver, shared, period, path));
|
||||||
|
*running = Some(RunningWatchdog { commands, task });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disable(&self) -> io::Result<()> {
|
||||||
|
let mut running = self.running.lock().await;
|
||||||
|
let Some(worker) = running.as_mut() else {
|
||||||
|
let mut state = self.shared.lock().unwrap();
|
||||||
|
state.running = false;
|
||||||
|
state.last_error = None;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (result_tx, result_rx) = oneshot::channel();
|
||||||
|
worker
|
||||||
|
.commands
|
||||||
|
.send(WorkerCommand::Disable(result_tx))
|
||||||
|
.await
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "watchdog worker stopped"))?;
|
||||||
|
match result_rx.await {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
if let Some(worker) = running.take() {
|
||||||
|
let _ = worker.task.await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => Err(error),
|
||||||
|
Err(_) => Err(io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"watchdog worker stopped",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> WatchdogRuntimeStatus {
|
||||||
|
let discovery = self.backend.discover();
|
||||||
|
let supported = discovery.as_ref().is_ok_and(|devices| !devices.is_empty());
|
||||||
|
let state = self.shared.lock().unwrap();
|
||||||
|
let reason = if let Err(error) = discovery {
|
||||||
|
Some(format!("Failed to discover hardware watchdog: {error}"))
|
||||||
|
} else if !supported {
|
||||||
|
Some("No hardware watchdog device found".to_string())
|
||||||
|
} else {
|
||||||
|
state.last_error.clone()
|
||||||
|
};
|
||||||
|
WatchdogRuntimeStatus {
|
||||||
|
supported,
|
||||||
|
running: state.running,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(&self, message: String) {
|
||||||
|
let mut state = self.shared.lock().unwrap();
|
||||||
|
state.running = false;
|
||||||
|
state.last_error = Some(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_worker(
|
||||||
|
mut device: Box<dyn Device>,
|
||||||
|
mut commands: mpsc::Receiver<WorkerCommand>,
|
||||||
|
shared: Arc<StdMutex<SharedState>>,
|
||||||
|
period: Duration,
|
||||||
|
path: PathBuf,
|
||||||
|
) {
|
||||||
|
let mut ticker = tokio::time::interval(period);
|
||||||
|
ticker.tick().await;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = ticker.tick() => {
|
||||||
|
match device.keep_alive() {
|
||||||
|
Ok(()) => {
|
||||||
|
let mut state = shared.lock().unwrap();
|
||||||
|
state.running = true;
|
||||||
|
state.last_error = None;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!("Watchdog keepalive failed: {error}");
|
||||||
|
tracing::error!("{} ({})", message, path.display());
|
||||||
|
let mut state = shared.lock().unwrap();
|
||||||
|
state.running = false;
|
||||||
|
state.last_error = Some(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command = commands.recv() => {
|
||||||
|
let Some(WorkerCommand::Disable(result_tx)) = command else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match device.disable() {
|
||||||
|
Ok(()) => {
|
||||||
|
let mut state = shared.lock().unwrap();
|
||||||
|
state.running = false;
|
||||||
|
state.last_error = None;
|
||||||
|
let _ = result_tx.send(Ok(()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!("Hardware watchdog cannot be safely disabled: {error}");
|
||||||
|
tracing::error!("{}; continuing keepalive", message);
|
||||||
|
let mut state = shared.lock().unwrap();
|
||||||
|
state.running = true;
|
||||||
|
state.last_error = Some(message);
|
||||||
|
let _ = result_tx.send(Err(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
struct FakeDevice {
|
||||||
|
feeds: Arc<AtomicUsize>,
|
||||||
|
feed_results: Arc<StdMutex<VecDeque<io::Result<()>>>>,
|
||||||
|
disable_result: Arc<StdMutex<Option<io::Result<()>>>>,
|
||||||
|
timeout: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device for FakeDevice {
|
||||||
|
fn keep_alive(&mut self) -> io::Result<()> {
|
||||||
|
self.feeds.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.feed_results
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or(Ok(()))
|
||||||
|
}
|
||||||
|
fn timeout(&mut self) -> io::Result<u32> {
|
||||||
|
Ok(self.timeout)
|
||||||
|
}
|
||||||
|
fn disable(&mut self) -> io::Result<()> {
|
||||||
|
self.disable_result.lock().unwrap().take().unwrap_or(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeBackend {
|
||||||
|
feeds: Arc<AtomicUsize>,
|
||||||
|
feed_results: Arc<StdMutex<VecDeque<io::Result<()>>>>,
|
||||||
|
disable_result: Arc<StdMutex<Option<io::Result<()>>>>,
|
||||||
|
opens: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend for FakeBackend {
|
||||||
|
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||||
|
Ok(vec![
|
||||||
|
DiscoveredWatchdog {
|
||||||
|
index: 0,
|
||||||
|
paths: vec![PathBuf::from("/dev/watchdog0")],
|
||||||
|
},
|
||||||
|
DiscoveredWatchdog {
|
||||||
|
index: 1,
|
||||||
|
paths: vec![PathBuf::from("/dev/watchdog1")],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn open(&self, _path: &Path) -> io::Result<Box<dyn Device>> {
|
||||||
|
self.opens.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(Box::new(FakeDevice {
|
||||||
|
feeds: self.feeds.clone(),
|
||||||
|
feed_results: self.feed_results.clone(),
|
||||||
|
disable_result: self.disable_result.clone(),
|
||||||
|
timeout: 3,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_controller(
|
||||||
|
disable_result: io::Result<()>,
|
||||||
|
) -> (WatchdogController, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||||
|
fake_controller_with_feeds(disable_result, VecDeque::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_controller_with_feeds(
|
||||||
|
disable_result: io::Result<()>,
|
||||||
|
feed_results: VecDeque<io::Result<()>>,
|
||||||
|
) -> (WatchdogController, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||||
|
let feeds = Arc::new(AtomicUsize::new(0));
|
||||||
|
let opens = Arc::new(AtomicUsize::new(0));
|
||||||
|
let backend = FakeBackend {
|
||||||
|
feeds: feeds.clone(),
|
||||||
|
feed_results: Arc::new(StdMutex::new(feed_results)),
|
||||||
|
disable_result: Arc::new(StdMutex::new(Some(disable_result))),
|
||||||
|
opens: opens.clone(),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
WatchdogController::with_backend(Arc::new(backend)),
|
||||||
|
feeds,
|
||||||
|
opens,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enable_feeds_immediately_and_disable_stops_worker() {
|
||||||
|
let (controller, feeds, opens) = fake_controller(Ok(()));
|
||||||
|
controller.enable().await.unwrap();
|
||||||
|
assert_eq!(opens.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(feeds.load(Ordering::SeqCst), 1);
|
||||||
|
assert!(controller.status().await.running);
|
||||||
|
controller.disable().await.unwrap();
|
||||||
|
assert!(!controller.status().await.running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_disable_keeps_watchdog_running() {
|
||||||
|
let (controller, feeds, _) = fake_controller(Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"nowayout",
|
||||||
|
)));
|
||||||
|
controller.enable().await.unwrap();
|
||||||
|
assert!(controller.disable().await.is_err());
|
||||||
|
assert!(controller.status().await.running);
|
||||||
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||||
|
assert!(feeds.load(Ordering::SeqCst) >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn initial_feed_failure_retries_the_same_device() {
|
||||||
|
let feed_results = VecDeque::from([
|
||||||
|
Err(io::Error::new(io::ErrorKind::Other, "temporary failure")),
|
||||||
|
Ok(()),
|
||||||
|
]);
|
||||||
|
let (controller, feeds, opens) = fake_controller_with_feeds(Ok(()), feed_results);
|
||||||
|
|
||||||
|
controller.enable().await.unwrap();
|
||||||
|
assert!(!controller.status().await.running);
|
||||||
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||||
|
assert!(controller.status().await.running);
|
||||||
|
assert_eq!(opens.load(Ordering::SeqCst), 1);
|
||||||
|
assert!(feeds.load(Ordering::SeqCst) >= 2);
|
||||||
|
controller.disable().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ mod rustdesk;
|
|||||||
mod stream;
|
mod stream;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
mod vnc;
|
mod vnc;
|
||||||
|
mod watchdog;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
pub use atx::{get_atx_config, update_atx_config};
|
pub use atx::{get_atx_config, update_atx_config};
|
||||||
@@ -43,6 +44,7 @@ pub use video::{get_video_config, update_video_config};
|
|||||||
pub use vnc::{
|
pub use vnc::{
|
||||||
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
||||||
};
|
};
|
||||||
|
pub use watchdog::{get_watchdog_config, update_watchdog_config};
|
||||||
pub use web::{get_web_config, update_web_config};
|
pub use web::{get_web_config, update_web_config};
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WatchdogConfigUpdate {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WatchdogConfigResponse {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub supported: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AuthConfigUpdate {
|
pub struct AuthConfigUpdate {
|
||||||
|
|||||||
79
src/web/handlers/config/watchdog.rs
Normal file
79
src/web/handlers/config/watchdog.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::apply::try_apply_lock;
|
||||||
|
use super::types::{WatchdogConfigResponse, WatchdogConfigUpdate};
|
||||||
|
|
||||||
|
async fn response(state: &AppState) -> WatchdogConfigResponse {
|
||||||
|
let runtime = state.watchdog.status().await;
|
||||||
|
WatchdogConfigResponse {
|
||||||
|
enabled: state.config.get().watchdog.enabled,
|
||||||
|
supported: runtime.supported,
|
||||||
|
running: runtime.running,
|
||||||
|
reason: runtime.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_watchdog_config(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Json<WatchdogConfigResponse> {
|
||||||
|
Json(response(&state).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_watchdog_config(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<WatchdogConfigUpdate>,
|
||||||
|
) -> Result<Json<WatchdogConfigResponse>> {
|
||||||
|
let _apply_guard = try_apply_lock(&state.config_apply_locks.watchdog, "watchdog")?;
|
||||||
|
let old_enabled = state.config.get().watchdog.enabled;
|
||||||
|
|
||||||
|
if req.enabled {
|
||||||
|
state.watchdog.enable().await.map_err(|error| {
|
||||||
|
AppError::Config(format!("Failed to enable hardware watchdog: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Err(error) = state
|
||||||
|
.config
|
||||||
|
.update(|config| config.watchdog.enabled = true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if !old_enabled {
|
||||||
|
if let Err(disable_error) = state.watchdog.disable().await {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to roll back watchdog after persistence error: {}",
|
||||||
|
disable_error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.watchdog.disable().await.map_err(|error| {
|
||||||
|
AppError::Config(format!(
|
||||||
|
"Hardware watchdog cannot be safely disabled; keepalive continues: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Err(error) = state
|
||||||
|
.config
|
||||||
|
.update(|config| config.watchdog.enabled = false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if old_enabled {
|
||||||
|
if let Err(enable_error) = state.watchdog.enable().await {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to restore watchdog after persistence error: {}",
|
||||||
|
enable_error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(response(&state).await))
|
||||||
|
}
|
||||||
@@ -170,6 +170,14 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
// Web server configuration
|
// Web server configuration
|
||||||
.route("/config/web", get(handlers::config::get_web_config))
|
.route("/config/web", get(handlers::config::get_web_config))
|
||||||
.route("/config/web", patch(handlers::config::update_web_config))
|
.route("/config/web", patch(handlers::config::update_web_config))
|
||||||
|
.route(
|
||||||
|
"/config/watchdog",
|
||||||
|
get(handlers::config::get_watchdog_config),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/watchdog",
|
||||||
|
patch(handlers::config::update_watchdog_config),
|
||||||
|
)
|
||||||
.route("/config/computer-use", get(handlers::computer_use_config))
|
.route("/config/computer-use", get(handlers::computer_use_config))
|
||||||
.route(
|
.route(
|
||||||
"/config/computer-use",
|
"/config/computer-use",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import type {
|
|||||||
FrpcConfigUpdate,
|
FrpcConfigUpdate,
|
||||||
WebConfigResponse,
|
WebConfigResponse,
|
||||||
WebConfigUpdate,
|
WebConfigUpdate,
|
||||||
|
WatchdogConfigResponse,
|
||||||
|
WatchdogConfigUpdate,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
|
|
||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
@@ -42,6 +44,16 @@ export const configApi = {
|
|||||||
getAll: () => request<AppConfig>('/config'),
|
getAll: () => request<AppConfig>('/config'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const watchdogConfigApi = {
|
||||||
|
get: () => request<WatchdogConfigResponse>('/config/watchdog', {}, { toastOnError: false }),
|
||||||
|
|
||||||
|
update: (config: WatchdogConfigUpdate) =>
|
||||||
|
request<WatchdogConfigResponse>('/config/watchdog', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}, { toastOnError: false }),
|
||||||
|
}
|
||||||
|
|
||||||
export const authConfigApi = {
|
export const authConfigApi = {
|
||||||
get: () => request<AuthConfig>('/config/auth'),
|
get: () => request<AuthConfig>('/config/auth'),
|
||||||
|
|
||||||
|
|||||||
@@ -813,6 +813,7 @@ export {
|
|||||||
rtspConfigApi,
|
rtspConfigApi,
|
||||||
vncConfigApi,
|
vncConfigApi,
|
||||||
webConfigApi,
|
webConfigApi,
|
||||||
|
watchdogConfigApi,
|
||||||
type RustDeskConfigResponse,
|
type RustDeskConfigResponse,
|
||||||
type RustDeskStatusResponse,
|
type RustDeskStatusResponse,
|
||||||
type RustDeskConfigUpdate,
|
type RustDeskConfigUpdate,
|
||||||
|
|||||||
@@ -513,11 +513,32 @@ export default {
|
|||||||
msdSubtitle: 'Manage Mass Storage Device image directory',
|
msdSubtitle: 'Manage Mass Storage Device image directory',
|
||||||
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
|
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
|
||||||
environmentSubtitle: 'System runtime environment and USB device maintenance',
|
environmentSubtitle: 'System runtime environment and USB device maintenance',
|
||||||
|
otherSubtitle: 'Additional system hardware controls',
|
||||||
aboutSubtitle: 'Online upgrade, version info and hardware overview',
|
aboutSubtitle: 'Online upgrade, version info and hardware overview',
|
||||||
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
||||||
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, RTSP, and Redfish access',
|
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, RTSP, and Redfish access',
|
||||||
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
||||||
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
||||||
|
watchdog: {
|
||||||
|
title: 'Hardware Watchdog',
|
||||||
|
description: 'Automatically use an available hardware watchdog while One-KVM is running.',
|
||||||
|
status: {
|
||||||
|
running: 'Running',
|
||||||
|
closed: 'Off',
|
||||||
|
unsupported: 'Unsupported',
|
||||||
|
error: 'Error',
|
||||||
|
},
|
||||||
|
safety: 'If One-KVM stops feeding the watchdog, the hardware may restart the system according to its watchdog policy.',
|
||||||
|
loadFailed: 'Failed to load hardware watchdog status',
|
||||||
|
toggleFailed: 'Failed to update hardware watchdog',
|
||||||
|
enableFailed: 'Failed to enable the hardware watchdog.',
|
||||||
|
unsupportedReason: 'No supported hardware watchdog was found.',
|
||||||
|
discoveryFailed: 'Failed to detect the hardware watchdog.',
|
||||||
|
openFailed: 'The hardware watchdog could not be opened.',
|
||||||
|
feedFailed: 'The hardware watchdog is not being fed successfully.',
|
||||||
|
disableFailed: 'The hardware watchdog cannot be safely disabled and keepalive will continue.',
|
||||||
|
abnormalReason: 'The hardware watchdog is in an abnormal state.',
|
||||||
|
},
|
||||||
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
||||||
deviceInfo: 'Device Info',
|
deviceInfo: 'Device Info',
|
||||||
deviceInfoDesc: 'Host system information',
|
deviceInfoDesc: 'Host system information',
|
||||||
|
|||||||
@@ -512,11 +512,32 @@ export default {
|
|||||||
msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录',
|
msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录',
|
||||||
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
|
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
|
||||||
environmentSubtitle: '系统级运行环境与 USB 设备维护',
|
environmentSubtitle: '系统级运行环境与 USB 设备维护',
|
||||||
|
otherSubtitle: '其他系统硬件控制',
|
||||||
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
|
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
|
||||||
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
||||||
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC、RTSP 与 Redfish 外部接入',
|
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC、RTSP 与 Redfish 外部接入',
|
||||||
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
||||||
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
||||||
|
watchdog: {
|
||||||
|
title: '硬件 Watchdog',
|
||||||
|
description: 'One-KVM 运行期间自动使用可用的硬件 Watchdog。',
|
||||||
|
status: {
|
||||||
|
running: '运行中',
|
||||||
|
closed: '已关闭',
|
||||||
|
unsupported: '不支持',
|
||||||
|
error: '异常',
|
||||||
|
},
|
||||||
|
safety: '如果 One-KVM 停止喂狗,硬件可能会按照 Watchdog 策略重启系统。',
|
||||||
|
loadFailed: '无法加载硬件 Watchdog 状态',
|
||||||
|
toggleFailed: '无法更新硬件 Watchdog',
|
||||||
|
enableFailed: '无法开启硬件 Watchdog。',
|
||||||
|
unsupportedReason: '未发现支持的硬件 Watchdog。',
|
||||||
|
discoveryFailed: '无法发现硬件 Watchdog。',
|
||||||
|
openFailed: '无法打开硬件 Watchdog。',
|
||||||
|
feedFailed: '硬件 Watchdog 当前未能正常喂狗。',
|
||||||
|
disableFailed: '硬件 Watchdog 无法安全停用,将继续喂狗。',
|
||||||
|
abnormalReason: '硬件 Watchdog 状态异常。',
|
||||||
|
},
|
||||||
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
||||||
deviceInfo: '设备信息',
|
deviceInfo: '设备信息',
|
||||||
deviceInfoDesc: '主机系统信息',
|
deviceInfoDesc: '主机系统信息',
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ export interface RedfishConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchdogConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
auth: AuthConfig;
|
auth: AuthConfig;
|
||||||
@@ -306,6 +310,7 @@ export interface AppConfig {
|
|||||||
vnc: VncConfig;
|
vnc: VncConfig;
|
||||||
rtsp: RtspConfig;
|
rtsp: RtspConfig;
|
||||||
redfish: RedfishConfig;
|
redfish: RedfishConfig;
|
||||||
|
watchdog: WatchdogConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update for a single ATX output binding */
|
/** Update for a single ATX output binding */
|
||||||
@@ -708,6 +713,17 @@ export interface VncStatusResponse {
|
|||||||
connection_count: number;
|
connection_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchdogConfigResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
running: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchdogConfigUpdate {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web server settings returned by `GET` / `PATCH /api/config/web`.
|
* Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
updateApi,
|
updateApi,
|
||||||
usbApi,
|
usbApi,
|
||||||
vncConfigApi,
|
vncConfigApi,
|
||||||
|
watchdogConfigApi,
|
||||||
type EncoderBackendInfo,
|
type EncoderBackendInfo,
|
||||||
type AuthConfig,
|
type AuthConfig,
|
||||||
type RustDeskConfigResponse,
|
type RustDeskConfigResponse,
|
||||||
@@ -49,6 +50,7 @@ import type {
|
|||||||
Ch9329DescriptorState,
|
Ch9329DescriptorState,
|
||||||
NetworkInterfaceInfo,
|
NetworkInterfaceInfo,
|
||||||
OtgNetworkStatus,
|
OtgNetworkStatus,
|
||||||
|
WatchdogConfigResponse,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
|
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
|
||||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||||
@@ -115,6 +117,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bot,
|
Bot,
|
||||||
|
TimerReset,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
@@ -140,6 +143,7 @@ const SETTINGS_SECTION_IDS = [
|
|||||||
'hid',
|
'hid',
|
||||||
'atx',
|
'atx',
|
||||||
'environment',
|
'environment',
|
||||||
|
'other',
|
||||||
'ext-ttyd',
|
'ext-ttyd',
|
||||||
'third-party-access',
|
'third-party-access',
|
||||||
'ext-remote-access',
|
'ext-remote-access',
|
||||||
@@ -165,6 +169,7 @@ const navGroups = computed(() => [
|
|||||||
{ id: 'hid', label: t('settings.hid'), icon: Keyboard },
|
{ id: 'hid', label: t('settings.hid'), icon: Keyboard },
|
||||||
{ id: 'atx', label: t('settings.atx'), icon: Power },
|
{ id: 'atx', label: t('settings.atx'), icon: Power },
|
||||||
{ id: 'environment', label: t('settings.environment'), icon: Server },
|
{ id: 'environment', label: t('settings.environment'), icon: Server },
|
||||||
|
{ id: 'other', label: t('settings.other'), icon: TimerReset },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,6 +268,9 @@ async function loadSectionData(section: SettingsSectionId) {
|
|||||||
case 'environment':
|
case 'environment':
|
||||||
await fetchUsbDevices()
|
await fetchUsbDevices()
|
||||||
return
|
return
|
||||||
|
case 'other':
|
||||||
|
await loadWatchdogConfig()
|
||||||
|
return
|
||||||
case 'ext-ttyd':
|
case 'ext-ttyd':
|
||||||
case 'ext-remote-access':
|
case 'ext-remote-access':
|
||||||
await loadExtensions()
|
await loadExtensions()
|
||||||
@@ -308,6 +316,77 @@ const authConfig = ref<AuthConfig>({
|
|||||||
})
|
})
|
||||||
const authConfigLoading = ref(false)
|
const authConfigLoading = ref(false)
|
||||||
|
|
||||||
|
const watchdogStatus = ref<WatchdogConfigResponse | null>(null)
|
||||||
|
const watchdogLoading = ref(false)
|
||||||
|
const watchdogError = ref('')
|
||||||
|
|
||||||
|
const watchdogStatusKey = computed(() => {
|
||||||
|
const status = watchdogStatus.value
|
||||||
|
if (!status) return 'closed'
|
||||||
|
if (!status.supported) return status.enabled ? 'error' : 'unsupported'
|
||||||
|
if (status.running) return 'running'
|
||||||
|
if (status.enabled) return 'error'
|
||||||
|
return 'closed'
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchdogDisplayReason = computed(() => {
|
||||||
|
const reason = watchdogStatus.value?.reason
|
||||||
|
if (!reason) return ''
|
||||||
|
if (reason.includes('No hardware watchdog device found')) return t('settings.watchdog.unsupportedReason')
|
||||||
|
if (reason.includes('discover')) return t('settings.watchdog.discoveryFailed')
|
||||||
|
if (reason.includes('open a hardware watchdog')) return t('settings.watchdog.openFailed')
|
||||||
|
if (reason.includes('keepalive')) return t('settings.watchdog.feedFailed')
|
||||||
|
if (reason.includes('safely disabled') || reason.includes('nowayout')) return t('settings.watchdog.disableFailed')
|
||||||
|
return t('settings.watchdog.abnormalReason')
|
||||||
|
})
|
||||||
|
|
||||||
|
function watchdogRequestError(error: unknown, action: 'enable' | 'disable'): string {
|
||||||
|
const message = error instanceof Error ? error.message : ''
|
||||||
|
if (message.includes('No hardware watchdog device found')) return t('settings.watchdog.unsupportedReason')
|
||||||
|
if (message.includes('cannot be safely disabled') || message.includes('nowayout')) {
|
||||||
|
return t('settings.watchdog.disableFailed')
|
||||||
|
}
|
||||||
|
return t(action === 'enable' ? 'settings.watchdog.enableFailed' : 'settings.watchdog.toggleFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchdogStatusClass = computed(() => {
|
||||||
|
switch (watchdogStatusKey.value) {
|
||||||
|
case 'running': return 'bg-green-500'
|
||||||
|
case 'error': return 'bg-red-500'
|
||||||
|
default: return 'bg-gray-400'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadWatchdogConfig() {
|
||||||
|
watchdogLoading.value = true
|
||||||
|
watchdogError.value = ''
|
||||||
|
try {
|
||||||
|
watchdogStatus.value = await watchdogConfigApi.get()
|
||||||
|
} catch (error) {
|
||||||
|
watchdogError.value = error instanceof Error ? error.message : t('settings.watchdog.loadFailed')
|
||||||
|
} finally {
|
||||||
|
watchdogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateWatchdog(enabled: boolean) {
|
||||||
|
if (!watchdogStatus.value || watchdogLoading.value) return
|
||||||
|
watchdogLoading.value = true
|
||||||
|
watchdogError.value = ''
|
||||||
|
try {
|
||||||
|
watchdogStatus.value = await watchdogConfigApi.update({ enabled })
|
||||||
|
} catch (error) {
|
||||||
|
watchdogError.value = watchdogRequestError(error, enabled ? 'enable' : 'disable')
|
||||||
|
try {
|
||||||
|
watchdogStatus.value = await watchdogConfigApi.get()
|
||||||
|
} catch {
|
||||||
|
// Preserve the last confirmed state when status refresh also fails.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
watchdogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extensions = ref<ExtensionsStatus | null>(null)
|
const extensions = ref<ExtensionsStatus | null>(null)
|
||||||
const extensionsLoading = ref(false)
|
const extensionsLoading = ref(false)
|
||||||
const extensionLogs = ref<Record<string, string[]>>({
|
const extensionLogs = ref<Record<string, string[]>>({
|
||||||
@@ -3603,6 +3682,46 @@ watch(isWindows, () => {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="activeSection === 'other'" class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<CardTitle>{{ t('settings.watchdog.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.watchdog.description') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
:aria-label="t('settings.watchdog.title')"
|
||||||
|
:model-value="watchdogStatus?.enabled ?? false"
|
||||||
|
:disabled="watchdogLoading || !watchdogStatus || (!watchdogStatus.supported && !watchdogStatus.enabled)"
|
||||||
|
@update:model-value="updateWatchdog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Loader2 v-if="watchdogLoading" class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
<span v-else class="h-2.5 w-2.5 rounded-full" :class="watchdogStatusClass" />
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ watchdogLoading
|
||||||
|
? t('common.loading')
|
||||||
|
: t(`settings.watchdog.status.${watchdogStatusKey}`) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="watchdogError || watchdogDisplayReason"
|
||||||
|
class="text-sm"
|
||||||
|
:class="watchdogStatusKey === 'error' || watchdogError ? 'text-destructive' : 'text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ watchdogError || watchdogDisplayReason }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50 px-3 py-2.5 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/20 dark:text-amber-200">
|
||||||
|
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<p>{{ t('settings.watchdog.safety') }}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
<div v-show="activeSection === 'network'" class="space-y-6">
|
<div v-show="activeSection === 'network'" class="space-y-6">
|
||||||
|
|
||||||
<!-- Auto-restart: restarting progress -->
|
<!-- Auto-restart: restarting progress -->
|
||||||
|
|||||||
Reference in New Issue
Block a user