Merge branch 'ui-refactor' into ui-refactor-ui
BIN
client/ui/assets/netbird-menu-dot-connected.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
client/ui/assets/netbird-menu-dot-connecting.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
client/ui/assets/netbird-menu-dot-error.png
Normal file
|
After Width: | Height: | Size: 433 B |
BIN
client/ui/assets/netbird-menu-dot-idle.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
client/ui/assets/netbird-menu-dot-login.png
Normal file
|
After Width: | Height: | Size: 475 B |
BIN
client/ui/assets/netbird-menu-dot-offline.png
Normal file
|
After Width: | Height: | Size: 456 B |
BIN
client/ui/build/banner.bmp
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -58,3 +58,25 @@ var iconUpdateDisconnectedMacOS []byte
|
||||
|
||||
//go:embed assets/netbird.png
|
||||
var iconWindow []byte
|
||||
|
||||
// Small colored dots shown next to the status menu entry. Rendered as
|
||||
// regular NSImage/HBITMAP/GTK menu-item icons (not template), so the
|
||||
// colours stay intact on every platform.
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connected.png
|
||||
var iconMenuDotConnected []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connecting.png
|
||||
var iconMenuDotConnecting []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-login.png
|
||||
var iconMenuDotLogin []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-error.png
|
||||
var iconMenuDotError []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-idle.png
|
||||
var iconMenuDotIdle []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-offline.png
|
||||
var iconMenuDotOffline []byte
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
@@ -185,6 +187,23 @@ func (s *Peers) Get(ctx context.Context) (Status, error) {
|
||||
return statusFromProto(resp), nil
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
// statusStreamLoop subscribes to the daemon's SubscribeStatus stream and
|
||||
// re-emits each FullStatus snapshot on the Wails event bus. The first
|
||||
// message is the current snapshot; subsequent messages fire on
|
||||
@@ -225,7 +244,9 @@ func (s *Peers) statusStreamLoop(ctx context.Context) {
|
||||
}
|
||||
stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
emitUnavailable()
|
||||
if isDaemonUnreachable(err) {
|
||||
emitUnavailable()
|
||||
}
|
||||
return fmt.Errorf("subscribe status: %w", err)
|
||||
}
|
||||
for {
|
||||
@@ -234,7 +255,9 @@ func (s *Peers) statusStreamLoop(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
emitUnavailable()
|
||||
if isDaemonUnreachable(err) {
|
||||
emitUnavailable()
|
||||
}
|
||||
return fmt.Errorf("status stream recv: %w", err)
|
||||
}
|
||||
unavailable = false
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
@@ -26,14 +27,15 @@ const (
|
||||
trayTooltip = "NetBird"
|
||||
|
||||
// Top-level menu entries.
|
||||
menuStatusDisconnected = "Disconnected"
|
||||
menuStatusDisconnected = "Disconnected"
|
||||
menuStatusDaemonUnavailable = "Not running"
|
||||
menuOpenNetBird = "Open NetBird"
|
||||
menuConnect = "Connect"
|
||||
menuDisconnect = "Disconnect"
|
||||
menuExitNode = "Exit Node"
|
||||
menuNetworks = "Networks"
|
||||
menuQuit = "Quit"
|
||||
menuOpenNetBird = "Open NetBird"
|
||||
menuConnect = "Connect"
|
||||
menuDisconnect = "Disconnect"
|
||||
menuExitNode = "Exit Node"
|
||||
menuNetworks = "Resources"
|
||||
menuProfiles = "Profiles"
|
||||
menuQuit = "Quit"
|
||||
|
||||
// Settings + diagnostics. The settings page replaces the Fyne tray's
|
||||
// Settings submenu (per-toggle checkboxes for SSH, auto-connect,
|
||||
@@ -68,11 +70,17 @@ const (
|
||||
notifySessionExpiredBody = "Your NetBird session has expired. Please log in again."
|
||||
|
||||
// Notification IDs (used to coalesce duplicate toasts).
|
||||
notifyIDUpdatePrefix = "netbird-update-"
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDSessionExpired = "netbird-session-expired"
|
||||
notifyIDUpdatePrefix = "netbird-update-"
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDSessionExpired = "netbird-session-expired"
|
||||
|
||||
// Daemon status strings mirroring internal.Status* — kept in sync
|
||||
// with client/internal/state.go.
|
||||
statusConnected = "Connected"
|
||||
statusConnecting = "Connecting"
|
||||
statusIdle = "Idle"
|
||||
statusError = "Error"
|
||||
// Daemon status string for an SSO session that has expired and needs
|
||||
// re-authentication. Mirrors internal.StatusSessionExpired.
|
||||
statusSessionExpired = "SessionExpired"
|
||||
@@ -114,11 +122,13 @@ type Tray struct {
|
||||
window *application.WebviewWindow
|
||||
svc TrayServices
|
||||
|
||||
menu *application.Menu
|
||||
statusItem *application.MenuItem
|
||||
upItem *application.MenuItem
|
||||
downItem *application.MenuItem
|
||||
exitNodeItem *application.MenuItem
|
||||
networksItem *application.MenuItem
|
||||
profileSubmenu *application.Menu
|
||||
settingsItem *application.MenuItem
|
||||
debugItem *application.MenuItem
|
||||
updateItem *application.MenuItem
|
||||
@@ -147,7 +157,8 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
t.tray = app.SystemTray.New()
|
||||
t.applyIcon()
|
||||
t.tray.SetTooltip(trayTooltip)
|
||||
t.tray.SetMenu(t.buildMenu())
|
||||
t.menu = t.buildMenu()
|
||||
t.tray.SetMenu(t.menu)
|
||||
// Left-click on the tray icon opens the menu on every platform. The
|
||||
// window is reached through the explicit "Open NetBird" entry. This
|
||||
// matches macOS NSStatusItem convention (click → menu), the Linux
|
||||
@@ -162,6 +173,13 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
app.Event.On(services.EventSystem, t.onSystemEvent)
|
||||
app.Event.On(services.EventUpdateAvailable, t.onUpdateAvailable)
|
||||
app.Event.On(services.EventUpdateProgress, t.onUpdateProgress)
|
||||
// Defer the first profile load until the macOS/GTK/Win32 menu impl is
|
||||
// live — Menu.Update() short-circuits while app.running is false, and
|
||||
// AppKit's main queue isn't ready earlier either (see d23ef34 InvokeSync
|
||||
// nil-deref).
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
go t.loadProfiles()
|
||||
})
|
||||
|
||||
go t.loadConfig()
|
||||
return t
|
||||
@@ -189,7 +207,8 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
// up unconditionally rather than swapping items at runtime.
|
||||
t.statusItem = menu.Add(menuStatusDisconnected).
|
||||
OnClick(func(*application.Context) { t.openRoute("/login") }).
|
||||
SetEnabled(false)
|
||||
SetEnabled(false).
|
||||
SetBitmap(iconMenuDotIdle)
|
||||
|
||||
menu.AddSeparator()
|
||||
// The tray icon's left-click handler is intentionally unbound (see
|
||||
@@ -197,6 +216,11 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
// menu entry on every platform.
|
||||
menu.Add(menuOpenNetBird).OnClick(func(*application.Context) { t.ShowWindow() })
|
||||
menu.AddSeparator()
|
||||
// Profiles submenu is populated asynchronously once the application
|
||||
// has started — Menu.Update() is a no-op before app.running is true,
|
||||
// so the initial fill is gated on the ApplicationStarted hook.
|
||||
t.profileSubmenu = menu.AddSubmenu(menuProfiles)
|
||||
menu.AddSeparator()
|
||||
// Only the action that applies to the current state is visible: Connect
|
||||
// when disconnected, Disconnect when connected. applyStatus swaps them on
|
||||
// each daemon status change.
|
||||
@@ -430,7 +454,7 @@ func (t *Tray) onUpdateProgress(ev *application.CustomEvent) {
|
||||
// otherwise spam Shell_NotifyIcon and the log.
|
||||
func (t *Tray) applyStatus(st services.Status) {
|
||||
t.mu.Lock()
|
||||
connected := strings.EqualFold(st.Status, "Connected")
|
||||
connected := strings.EqualFold(st.Status, statusConnected)
|
||||
iconChanged := connected != t.connected || st.Status != t.lastStatus
|
||||
// Detect the transition into SessionExpired: the daemon emits the
|
||||
// state on every Status snapshot for as long as the session stays
|
||||
@@ -457,32 +481,46 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
strings.EqualFold(st.Status, statusSessionExpired) ||
|
||||
strings.EqualFold(st.Status, statusLoginFailed)
|
||||
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
|
||||
connecting := strings.EqualFold(st.Status, statusConnecting)
|
||||
if t.statusItem != nil {
|
||||
// When the daemon needs re-authentication the status row turns
|
||||
// into the actionable Login entry — Connect would only fail.
|
||||
// When the daemon socket is unreachable, swap the label to make
|
||||
// the cause obvious; Connect/Disconnect would just fail.
|
||||
label := st.Status
|
||||
if daemonUnavailable {
|
||||
switch {
|
||||
case daemonUnavailable:
|
||||
label = menuStatusDaemonUnavailable
|
||||
case strings.EqualFold(st.Status, statusIdle):
|
||||
label = menuStatusDisconnected
|
||||
}
|
||||
t.statusItem.SetLabel(label)
|
||||
t.statusItem.SetEnabled(needsLogin)
|
||||
t.applyStatusIndicator(st.Status)
|
||||
}
|
||||
if t.upItem != nil {
|
||||
t.upItem.SetHidden(connected || needsLogin || daemonUnavailable)
|
||||
t.upItem.SetEnabled(!connected && !needsLogin && !daemonUnavailable)
|
||||
// Hide Connect whenever an Up action would be a no-op or would
|
||||
// only fail: tunnel already up, daemon mid-connect (Disconnect
|
||||
// takes over the slot so the user can abort), login required,
|
||||
// or daemon socket unreachable.
|
||||
t.upItem.SetHidden(connected || connecting || needsLogin || daemonUnavailable)
|
||||
t.upItem.SetEnabled(!connected && !connecting && !needsLogin && !daemonUnavailable)
|
||||
}
|
||||
if t.downItem != nil {
|
||||
t.downItem.SetHidden(!connected)
|
||||
t.downItem.SetEnabled(connected)
|
||||
// Disconnect is the abort path while the daemon is still
|
||||
// retrying the management dial — without it the user has no
|
||||
// way to stop the loop short of killing the daemon.
|
||||
t.downItem.SetHidden(!connected && !connecting)
|
||||
t.downItem.SetEnabled(connected || connecting)
|
||||
}
|
||||
// Exit Node and Resources surface tunnel-routed state, so only
|
||||
// expose them while the tunnel is up. Settings/Debug-Bundle just
|
||||
// need the daemon socket reachable.
|
||||
if t.exitNodeItem != nil {
|
||||
t.exitNodeItem.SetEnabled(connected)
|
||||
}
|
||||
// Settings, Networks and Debug Bundle all drive daemon RPCs from
|
||||
// their respective frontend routes — disable them while the daemon
|
||||
// socket is unreachable so the user doesn't land on a page that
|
||||
// would only fail to load.
|
||||
if t.networksItem != nil {
|
||||
t.networksItem.SetEnabled(!daemonUnavailable)
|
||||
t.networksItem.SetEnabled(connected)
|
||||
}
|
||||
if t.settingsItem != nil {
|
||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||
@@ -519,18 +557,55 @@ func (t *Tray) handleSessionExpired() {
|
||||
}
|
||||
|
||||
func (t *Tray) rebuildExitNodes(nodes []string) {
|
||||
if t.exitNodeItem == nil {
|
||||
return
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
t.exitNodeItem.SetEnabled(false)
|
||||
if t.exitNodeItem == nil || len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
sub := application.NewMenu()
|
||||
for _, fqdn := range nodes {
|
||||
sub.AddCheckbox(fqdn, false)
|
||||
}
|
||||
t.exitNodeItem.SetEnabled(true)
|
||||
}
|
||||
|
||||
// applyStatusIndicator sets the small coloured dot shown on the status
|
||||
// menu entry. The dot mirrors the tray icon's state through a fixed
|
||||
// palette: green for Connected, yellow for Connecting, blue for the
|
||||
// login states, red for hard errors, grey for the idle/disconnected
|
||||
// pair and a darker grey when the daemon socket is unreachable.
|
||||
//
|
||||
// Wails v3 alpha's setMenuItemBitmap calls NSMenuItem.setImage from
|
||||
// whichever thread invoked SetBitmap — unlike setMenuItemLabel/Disabled/
|
||||
// Hidden/Checked which dispatch_sync onto the main queue. The off-thread
|
||||
// AppKit call leaves the visible dot stale until the next time the menu
|
||||
// is reopened (close+reopen workaround). Rebuilding via tray.SetMenu
|
||||
// reruns processMenu inside InvokeSync, so the bitmap is applied to a
|
||||
// fresh NSMenuItem on the main thread and macOS picks it up.
|
||||
func (t *Tray) applyStatusIndicator(status string) {
|
||||
if t.statusItem == nil {
|
||||
return
|
||||
}
|
||||
t.statusItem.SetBitmap(statusIndicatorBitmap(status))
|
||||
if t.menu != nil {
|
||||
t.tray.SetMenu(t.menu)
|
||||
}
|
||||
}
|
||||
|
||||
func statusIndicatorBitmap(status string) []byte {
|
||||
switch {
|
||||
case strings.EqualFold(status, statusConnected):
|
||||
return iconMenuDotConnected
|
||||
case strings.EqualFold(status, statusConnecting):
|
||||
return iconMenuDotConnecting
|
||||
case strings.EqualFold(status, statusNeedsLogin),
|
||||
strings.EqualFold(status, statusSessionExpired):
|
||||
return iconMenuDotLogin
|
||||
case strings.EqualFold(status, statusLoginFailed),
|
||||
strings.EqualFold(status, statusError):
|
||||
return iconMenuDotError
|
||||
case strings.EqualFold(status, services.StatusDaemonUnavailable):
|
||||
return iconMenuDotOffline
|
||||
default:
|
||||
return iconMenuDotIdle
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) applyIcon() {
|
||||
@@ -561,8 +636,8 @@ func (t *Tray) iconForState() (icon, dark []byte) {
|
||||
statusLabel := t.lastStatus
|
||||
t.mu.Unlock()
|
||||
|
||||
connecting := strings.EqualFold(statusLabel, "Connecting")
|
||||
errored := strings.EqualFold(statusLabel, "Error") ||
|
||||
connecting := strings.EqualFold(statusLabel, statusConnecting)
|
||||
errored := strings.EqualFold(statusLabel, statusError) ||
|
||||
strings.EqualFold(statusLabel, services.StatusDaemonUnavailable)
|
||||
needsLogin := strings.EqualFold(statusLabel, statusNeedsLogin) ||
|
||||
strings.EqualFold(statusLabel, statusSessionExpired) ||
|
||||
@@ -636,6 +711,128 @@ func (t *Tray) loadConfig() {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// loadProfiles refreshes the Profiles submenu from the daemon. Each
|
||||
// entry is a checkbox showing the active profile and switches on click.
|
||||
// Called once on ApplicationStarted and again after a successful switch
|
||||
// so the checkmark moves to the new active profile.
|
||||
func (t *Tray) loadProfiles() {
|
||||
if t.profileSubmenu == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
username, err := t.svc.Profiles.Username()
|
||||
if err != nil {
|
||||
log.Debugf("get current user: %v", err)
|
||||
return
|
||||
}
|
||||
profiles, err := t.svc.Profiles.List(ctx, username)
|
||||
if err != nil {
|
||||
log.Debugf("list profiles: %v", err)
|
||||
return
|
||||
}
|
||||
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
|
||||
|
||||
log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username)
|
||||
t.profileSubmenu.Clear()
|
||||
for _, p := range profiles {
|
||||
name := p.Name
|
||||
active := p.IsActive
|
||||
log.Infof("tray loadProfiles: profile=%q active=%v", name, active)
|
||||
item := t.profileSubmenu.AddCheckbox(name, active)
|
||||
item.OnClick(func(*application.Context) {
|
||||
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
|
||||
if active {
|
||||
return
|
||||
}
|
||||
t.switchProfile(name)
|
||||
})
|
||||
}
|
||||
// Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on
|
||||
// darwin that never replaces the empty NSMenu attached to the parent
|
||||
// menu item at initial setup — so the visible Profiles menu stays
|
||||
// frozen on the snapshot taken when the tray was registered. Re-running
|
||||
// SetMenu on the top-level rebuilds the entire NSMenu tree against the
|
||||
// cached pointer and is the only path that propagates submenu changes.
|
||||
if t.menu != nil {
|
||||
t.tray.SetMenu(t.menu)
|
||||
} else {
|
||||
t.profileSubmenu.Update()
|
||||
}
|
||||
}
|
||||
|
||||
// switchProfile runs the daemon RPC in a goroutine so the menu click
|
||||
// returns immediately, then reloads the submenu to move the checkmark.
|
||||
//
|
||||
// Reconnect policy by previous daemon status:
|
||||
//
|
||||
// ┌─────────────────┬──────────────────────┬───────────────────────────────────┐
|
||||
// │ Previous status │ Tray action │ Rationale │
|
||||
// ├─────────────────┼──────────────────────┼───────────────────────────────────┤
|
||||
// │ Connected │ Switch + Down + Up │ Reconnect with the new profile. │
|
||||
// │ Connecting │ Switch + Down + Up │ Stop the retry loop still dialing │
|
||||
// │ │ │ the old management server, then │
|
||||
// │ │ │ restart with new config. │
|
||||
// │ Idle │ Switch only │ User chose to be offline; don't │
|
||||
// │ │ │ silently flip the daemon online. │
|
||||
// │ NeedsLogin │ Switch only │ Login needs interactive SSO; let │
|
||||
// │ LoginFailed │ Switch only │ the user trigger the next step. │
|
||||
// │ SessionExpired │ Switch only │ │
|
||||
// └─────────────────┴──────────────────────┴───────────────────────────────────┘
|
||||
//
|
||||
// Rule of thumb: auto-reconnect only when the daemon was actively trying
|
||||
// to be online (Connected or Connecting). Any other state is a deliberate
|
||||
// waiting point — keep the user in control of the next action.
|
||||
func (t *Tray) switchProfile(name string) {
|
||||
t.mu.Lock()
|
||||
prevStatus := t.lastStatus
|
||||
t.mu.Unlock()
|
||||
wasActive := strings.EqualFold(prevStatus, statusConnected) ||
|
||||
strings.EqualFold(prevStatus, statusConnecting)
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
username, err := t.svc.Profiles.Username()
|
||||
if err != nil {
|
||||
log.Errorf("get current user: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("tray switchProfile: sending SwitchProfile RPC profile=%q user=%q prevStatus=%q wasActive=%v",
|
||||
name, username, prevStatus, wasActive)
|
||||
if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{
|
||||
ProfileName: name,
|
||||
Username: username,
|
||||
}); err != nil {
|
||||
log.Errorf("tray switchProfile: SwitchProfile RPC failed profile=%q err=%v", name, err)
|
||||
t.notifyError(fmt.Sprintf("Failed to switch to %s", name))
|
||||
return
|
||||
}
|
||||
log.Infof("tray switchProfile: SwitchProfile RPC succeeded profile=%q", name)
|
||||
|
||||
if wasActive {
|
||||
// Stop the in-flight (or established) connection that's still
|
||||
// pointing at the previous profile's management server, then
|
||||
// bring it back up against the new profile.
|
||||
log.Infof("tray switchProfile: was active (%s), reconnecting with new profile %q", prevStatus, name)
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("tray switchProfile: Down failed: %v", err)
|
||||
}
|
||||
if err := t.svc.Connection.Up(ctx, services.UpParams{
|
||||
ProfileName: name,
|
||||
Username: username,
|
||||
}); err != nil {
|
||||
log.Errorf("tray switchProfile: Up failed: %v", err)
|
||||
t.notifyError(fmt.Sprintf("Failed to reconnect with %s", name))
|
||||
}
|
||||
}
|
||||
|
||||
t.loadProfiles()
|
||||
}()
|
||||
}
|
||||
|
||||
// notify wraps the Wails notification service with the tray's standard
|
||||
// id-prefix scheme and swallows errors (notifications are best-effort).
|
||||
func (t *Tray) notify(title, body, id string) {
|
||||
|
||||