Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.2] - 2026-03-09

## Fixed
- **Healthcheks failing**: Resolved issues with service health checks not executing properly when main page was returning `4XX` or `3XX` status codes. Added optional healthcheck endpoint.

## [0.1.1] - 2026-03-08

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "netweave"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
description = "Lightweight IPAM & HomeLab Dashboard"
license = "Apache-2.0"
Expand Down
6 changes: 6 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Todo
- [ ] Kea integration
- [ ] Unifi integration
- [ ] Proxmox API Integration - importing devices
- [ ] Add separate develop docker image
- [ ] Add checks to prevent overwriting docker x.x.x images
- [ ] Add husky or prek and run commands like cargo fmt before commit
- [ ] Add `compose.yml` version update to `bump-version` script. Compose should use explicit x.x version tag to prevnt breaking changes.
- [ ] Add customizable roles (from OIDC) to the dashboard services. Consider if Authentik integration could be used.
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

services:
app:
image: mi7chal/netweave:latest
image: mi7chal/netweave:0.1
container_name: netweave
environment:
DATABASE_URL: "postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@db:5432/${DATABASE_NAME}"
Expand Down
3 changes: 3 additions & 0 deletions src/db/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ impl Db {
id: service.id,
name: service.name,
base_url: service.base_url,
health_endpoint: service.health_endpoint,
is_public: service.is_public.unwrap_or(false),
total_checks: service.total_checks.unwrap_or(0),
successful_checks: service.successful_checks.unwrap_or(0),
Expand Down Expand Up @@ -49,6 +50,7 @@ impl Db {
id: Set(new_id),
name: Set(params.name),
base_url: Set(params.base_url),
health_endpoint: Set(params.health_endpoint),
device_id: Set(params.device_id),
is_public: Set(Some(params.is_public)),
icon_url: Set(params.icon_url),
Expand All @@ -72,6 +74,7 @@ impl Db {

service.name = Set(params.name);
service.base_url = Set(params.base_url);
service.health_endpoint = Set(params.health_endpoint);
service.device_id = Set(params.device_id);
service.is_public = Set(Some(params.is_public));
service.icon_url = Set(params.icon_url);
Expand Down
1 change: 1 addition & 0 deletions src/models/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub struct DashboardService {
pub id: Uuid,
pub name: String,
pub base_url: String,
pub health_endpoint: Option<String>,
pub is_public: bool,
pub total_checks: i32,
pub successful_checks: i32,
Expand Down
2 changes: 2 additions & 0 deletions src/models/payloads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub struct CreateServicePayload {
pub base_url: String,
#[serde(default, deserialize_with = "validation::deserialize_optional_string")]
pub device_id: Option<Uuid>,
#[serde(default, deserialize_with = "validation::deserialize_optional_string")]
pub health_endpoint: Option<String>,
#[serde(default)]
pub is_public: bool,
#[serde(default, deserialize_with = "validation::deserialize_optional_string")]
Expand Down
13 changes: 12 additions & 1 deletion src/monitoring/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,18 @@ pub async fn start_monitoring(state: AppState) {
async fn check_all_services(state: &AppState, client: &reqwest::Client) -> anyhow::Result<()> {
let services = state.db.list_dashboard_services().await?;

// todo consider if may be executed recurently and check if db pool is not kept for too long
for service in services {
let url = service.base_url;
let url = if let Some(endpoint) = &service.health_endpoint {
format!(
"{}/{}",
service.base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
)
} else {
service.base_url
};

let (status, is_success) = check_service_status_with_retry(client, &url).await;

state
Expand Down Expand Up @@ -61,6 +71,7 @@ async fn check_service_status_with_retry(
) -> (ServiceStatus, bool) {
for attempt in 1..=RETRY_ATTEMPTS {
match client.get(url).send().await {
// todo what if service returns 3xx or 4xx? what would plex return?
Ok(response) if response.status().is_success() => return (ServiceStatus::Up, true),
Ok(response) => {
if response.status().is_server_error() && attempt < RETRY_ATTEMPTS {
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@netweave/web",
"private": true,
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
254 changes: 180 additions & 74 deletions web/src/components/ServiceDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
Expand All @@ -11,81 +24,174 @@ import type { Service, DeviceListView } from "@/types/api";
import useSWR from "swr";

interface ServiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
initialData?: Service | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
initialData?: Service | null;
}

export function ServiceDialog({ open, onOpenChange, onSaved, initialData }: ServiceDialogProps) {
const isEdit = !!initialData;
const { data: devices = [] } = useSWR<DeviceListView[]>("/api/devices", fetchApi);
const [formData, setFormData] = useState<Partial<Service>>({});
export function ServiceDialog({
open,
onOpenChange,
onSaved,
initialData,
}: ServiceDialogProps) {
const isEdit = !!initialData;
const { data: devices = [] } = useSWR<DeviceListView[]>(
"/api/devices",
fetchApi,
);
const [formData, setFormData] = useState<Partial<Service>>({});

useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFormData(initialData ?? { is_public: false });
}
}, [open, initialData]);
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFormData(initialData ?? { is_public: false });
}
}, [open, initialData]);

const handleSave = async () => {
try {
const url = isEdit ? `/api/services/${initialData!.id}` : "/api/services";
await fetchApi(url, {
method: isEdit ? "PUT" : "POST",
body: JSON.stringify(formData),
});
onSaved();
onOpenChange(false);
toast.success("Service saved", { description: `Successfully saved ${formData.name || "service"}` });
} catch (e) { console.error(e); }
};
const handleSave = async () => {
try {
const url = isEdit ? `/api/services/${initialData!.id}` : "/api/services";
await fetchApi(url, {
method: isEdit ? "PUT" : "POST",
body: JSON.stringify(formData),
});
onSaved();
onOpenChange(false);
toast.success("Service saved", {
description: `Successfully saved ${formData.name || "service"}`,
});
} catch (e) {
console.error(e);
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px] bg-card/80 backdrop-blur-2xl border-border/40 shadow-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">{isEdit ? "Edit Service" : "New Service"}</DialogTitle>
<DialogDescription className="text-muted-foreground/80">Configure service details and monitoring.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-6">
<div className="grid gap-2">
<Label htmlFor="name" className="text-sm font-medium">Name</Label>
<Input id="name" placeholder="Plex" value={formData.name || ""} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg" />
</div>
<div className="grid gap-2">
<Label htmlFor="url" className="text-sm font-medium">URL</Label>
<Input id="url" placeholder="http://192.168.1.50:32400" value={formData.base_url || ""} onChange={(e) => setFormData({ ...formData, base_url: e.target.value })} className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg" />
</div>
<div className="grid gap-2">
<Label className="text-sm font-medium">Link to Device</Label>
<Select value={formData.device_id || "none"} onValueChange={(val) => setFormData({ ...formData, device_id: val === "none" ? undefined : val })}>
<SelectTrigger className="bg-secondary/40 border-border/40 focus:ring-primary/40 focus:border-primary/50 transition-all rounded-lg"><SelectValue placeholder="Select a device (Optional)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{devices.map(device => <SelectItem key={device.id} value={device.id}>{device.hostname}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border border-border/40 bg-secondary/20 p-4 transition-all hover:bg-secondary/40">
<div className="space-y-0.5">
<Label className="text-base font-medium">Publicly Visible</Label>
<p className="text-sm text-muted-foreground/80">Show this service on the public dashboard.</p>
</div>
<Switch checked={formData.is_public} onCheckedChange={(checked) => setFormData({ ...formData, is_public: checked })} />
</div>
<div className="grid gap-2">
<Label htmlFor="icon_url" className="text-sm font-medium">Icon URL (Optional)</Label>
<Input id="icon_url" placeholder="https://example.com/icon.png" value={formData.icon_url || ""} onChange={(e) => setFormData({ ...formData, icon_url: e.target.value })} className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg" />
<p className="text-xs text-muted-foreground">Overrides the auto-discovered icon if provided.</p>
</div>
</div>
<DialogFooter className="border-t border-border/20 pt-4 mt-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="hover:bg-secondary/60">Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px] bg-card/80 backdrop-blur-2xl border-border/40 shadow-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
{isEdit ? "Edit Service" : "New Service"}
</DialogTitle>
<DialogDescription className="text-muted-foreground/80">
Configure service details and monitoring.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-6">
<div className="grid gap-2">
<Label htmlFor="name" className="text-sm font-medium">
Name
</Label>
<Input
id="name"
placeholder="Plex"
value={formData.name || ""}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url" className="text-sm font-medium">
URL
</Label>
<Input
id="url"
placeholder="http://192.168.1.50:32400"
value={formData.base_url || ""}
onChange={(e) =>
setFormData({ ...formData, base_url: e.target.value })
}
className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="health_endpoint" className="text-sm font-medium">
Health Endpoint (Optional)
</Label>
<Input
id="health_endpoint"
placeholder="/identity"
value={formData.health_endpoint || ""}
onChange={(e) =>
setFormData({ ...formData, health_endpoint: e.target.value })
}
className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg"
/>
<p className="text-xs text-muted-foreground">
Path to check for service health (e.g. /health). If empty, base
URL is used.
</p>
</div>
<div className="grid gap-2">
<Label className="text-sm font-medium">Link to Device</Label>
<Select
value={formData.device_id || "none"}
onValueChange={(val) =>
setFormData({
...formData,
device_id: val === "none" ? undefined : val,
})
}
>
<SelectTrigger className="bg-secondary/40 border-border/40 focus:ring-primary/40 focus:border-primary/50 transition-all rounded-lg">
<SelectValue placeholder="Select a device (Optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.hostname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border border-border/40 bg-secondary/20 p-4 transition-all hover:bg-secondary/40">
<div className="space-y-0.5">
<Label className="text-base font-medium">Publicly Visible</Label>
<p className="text-sm text-muted-foreground/80">
Show this service on the public dashboard.
</p>
</div>
<Switch
checked={formData.is_public}
onCheckedChange={(checked) =>
setFormData({ ...formData, is_public: checked })
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="icon_url" className="text-sm font-medium">
Icon URL (Optional)
</Label>
<Input
id="icon_url"
placeholder="https://example.com/icon.png"
value={formData.icon_url || ""}
onChange={(e) =>
setFormData({ ...formData, icon_url: e.target.value })
}
className="bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg"
/>
<p className="text-xs text-muted-foreground">
Overrides the auto-discovered icon if provided.
</p>
</div>
</div>
<DialogFooter className="border-t border-border/20 pt-4 mt-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="hover:bg-secondary/60"
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading