app: Surface Ethernet uplink in the UI and gate wpa_supplicant access
When the configured Ethernet interface holds a DHCP-assigned IPv4 at
startup, the app now skips wifi.Initialize / StartEventMonitoring and
guards every wifi.* wrapper against a nil backend. This prevents D-Bus
calls to fi.w1.wpa_supplicant1 from re-activating the daemon via
dbus-activation, honoring the "do nothing" intent of the Ethernet path.
The probed state is exposed in SystemStatus and rendered in the header
as a third pill ("Ethernet · <IP>"); a new "disabled" connectionState
covers the WiFi pill in this mode.
This commit is contained in:
parent
5a3942f351
commit
8b1debdddc
7 changed files with 166 additions and 37 deletions
|
|
@ -273,6 +273,10 @@ function updateStatusDisplay(status) {
|
||||||
wifiDot.className = 'status-dot active';
|
wifiDot.className = 'status-dot active';
|
||||||
wifiText.textContent = `Roaming: ${status.connectedSSID}`;
|
wifiText.textContent = `Roaming: ${status.connectedSSID}`;
|
||||||
break;
|
break;
|
||||||
|
case 'disabled':
|
||||||
|
wifiDot.className = 'status-dot offline';
|
||||||
|
wifiText.textContent = 'WiFi désactivé';
|
||||||
|
break;
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
default:
|
default:
|
||||||
wifiDot.className = 'status-dot offline';
|
wifiDot.className = 'status-dot offline';
|
||||||
|
|
@ -280,6 +284,16 @@ function updateStatusDisplay(status) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Ethernet uplink badge
|
||||||
|
const ethernetStatus = document.getElementById('ethernetStatus');
|
||||||
|
const ethernetText = ethernetStatus.querySelector('.status-text');
|
||||||
|
if (status.ethernetStatus?.active) {
|
||||||
|
ethernetText.textContent = `Ethernet · ${status.ethernetStatus.ipv4 || status.ethernetStatus.interface}`;
|
||||||
|
ethernetStatus.hidden = false;
|
||||||
|
} else {
|
||||||
|
ethernetStatus.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Update hotspot status badge
|
// Update hotspot status badge
|
||||||
const hotspotStatus = document.getElementById('hotspotStatus');
|
const hotspotStatus = document.getElementById('hotspotStatus');
|
||||||
const hotspotDot = hotspotStatus.querySelector('.status-dot');
|
const hotspotDot = hotspotStatus.querySelector('.status-dot');
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@
|
||||||
<span class="status-dot offline"></span>
|
<span class="status-dot offline"></span>
|
||||||
<span class="status-text">Disconnected</span>
|
<span class="status-text">Disconnected</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="status-badge" id="ethernetStatus" hidden>
|
||||||
|
<span class="status-dot active"></span>
|
||||||
|
<span class="status-text">Ethernet</span>
|
||||||
|
</div>
|
||||||
<div class="status-badge" id="hotspotStatus">
|
<div class="status-badge" id="hotspotStatus">
|
||||||
<span class="status-dot active"></span>
|
<span class="status-dot active"></span>
|
||||||
<span class="status-text">Hotspot actif</span>
|
<span class="status-text">Hotspot actif</span>
|
||||||
|
|
|
||||||
|
|
@ -57,20 +57,28 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||||
// Store config reference
|
// Store config reference
|
||||||
a.Config = cfg
|
a.Config = cfg
|
||||||
|
|
||||||
// If Ethernet uplink is not already providing connectivity (no DHCP lease
|
// Decide whether the Ethernet uplink already provides connectivity. When
|
||||||
// on the configured interface), bring up wpa_supplicant so the WiFi
|
// it does we deliberately skip every wpa_supplicant interaction below —
|
||||||
// backend has something to talk to.
|
// the daemon is dbus-activatable, so any call into the WiFi backend
|
||||||
ensureUplink(cfg.EthernetInterface)
|
// would re-spawn it and undo this choice.
|
||||||
|
eth := ensureUplink(cfg.EthernetInterface)
|
||||||
|
a.StatusMutex.Lock()
|
||||||
|
a.Status.EthernetStatus = eth
|
||||||
|
a.StatusMutex.Unlock()
|
||||||
|
|
||||||
// Initialize WiFi backend
|
if !eth.Active {
|
||||||
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
|
// Initialize WiFi backend
|
||||||
return err
|
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Start WiFi event monitoring
|
// Start WiFi event monitoring
|
||||||
if err := wifi.StartEventMonitoring(); err != nil {
|
if err := wifi.StartEventMonitoring(); err != nil {
|
||||||
log.Printf("Warning: WiFi event monitoring failed: %v", err)
|
log.Printf("Warning: WiFi event monitoring failed: %v", err)
|
||||||
// Don't fail - polling fallback still works
|
// Don't fail - polling fallback still works
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Skipping WiFi backend init: Ethernet uplink active on %s", eth.Interface)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize station backend
|
// Initialize station backend
|
||||||
|
|
@ -210,10 +218,32 @@ func (a *App) periodicStatusUpdate() {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var eth *models.EthernetStatus
|
||||||
|
if a.Config != nil {
|
||||||
|
if e, err := probeEthernet(a.Config.EthernetInterface); err == nil {
|
||||||
|
eth = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.StatusMutex.Lock()
|
a.StatusMutex.Lock()
|
||||||
a.Status.Connected = wifi.IsConnected()
|
if eth != nil {
|
||||||
a.Status.ConnectionState = wifi.GetConnectionState()
|
a.Status.EthernetStatus = eth
|
||||||
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
|
}
|
||||||
|
|
||||||
|
// Skip every wifi.* call when the Ethernet uplink is the chosen
|
||||||
|
// path: the WiFi backend isn't initialized in that mode and the
|
||||||
|
// wrappers would otherwise return zero values; either way we don't
|
||||||
|
// want to risk dbus-activating wpa_supplicant from this hot loop.
|
||||||
|
ethActive := a.Status.EthernetStatus != nil && a.Status.EthernetStatus.Active
|
||||||
|
if !ethActive {
|
||||||
|
a.Status.Connected = wifi.IsConnected()
|
||||||
|
a.Status.ConnectionState = wifi.GetConnectionState()
|
||||||
|
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
|
||||||
|
} else {
|
||||||
|
a.Status.Connected = false
|
||||||
|
a.Status.ConnectionState = "disabled"
|
||||||
|
a.Status.ConnectedSSID = ""
|
||||||
|
}
|
||||||
a.Status.Uptime = getSystemUptime()
|
a.Status.Uptime = getSystemUptime()
|
||||||
|
|
||||||
// Get detailed hotspot status
|
// Get detailed hotspot status
|
||||||
|
|
|
||||||
|
|
@ -5,50 +5,64 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nemunaire/repeater/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hasDHCPAddress reports whether iface holds an IPv4 address that ip(8) marks
|
// probeEthernet reports the wired uplink state of iface by parsing
|
||||||
// as "dynamic" — the flag set on addresses with a finite valid_lft, which is
|
// `ip -4 -o addr show dev <iface>`. An IPv4 line carrying the "dynamic" flag
|
||||||
// how DHCP-assigned leases appear (static addresses are "permanent").
|
// (set by ip(8) on addresses with a finite valid_lft) is treated as a
|
||||||
func hasDHCPAddress(iface string) (bool, error) {
|
// DHCP-assigned lease — that's the signal we use to decide whether the box
|
||||||
|
// already has connectivity through Ethernet.
|
||||||
|
func probeEthernet(iface string) (*models.EthernetStatus, error) {
|
||||||
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output()
|
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ip addr show %s: %w", iface, err)
|
return nil, fmt.Errorf("ip addr show %s: %w", iface, err)
|
||||||
}
|
}
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
|
var addr string
|
||||||
hasInet := false
|
hasInet := false
|
||||||
hasDynamic := false
|
hasDynamic := false
|
||||||
for _, f := range fields {
|
for i, f := range fields {
|
||||||
switch f {
|
switch f {
|
||||||
case "inet":
|
case "inet":
|
||||||
hasInet = true
|
hasInet = true
|
||||||
|
if i+1 < len(fields) {
|
||||||
|
addr = fields[i+1]
|
||||||
|
}
|
||||||
case "dynamic":
|
case "dynamic":
|
||||||
hasDynamic = true
|
hasDynamic = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasInet && hasDynamic {
|
if hasInet && hasDynamic {
|
||||||
return true, nil
|
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
|
||||||
|
addr = addr[:slash]
|
||||||
|
}
|
||||||
|
return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return &models.EthernetStatus{Active: false, Interface: iface}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureUplink boots the WiFi uplink only if Ethernet is not already providing
|
// ensureUplink returns the current Ethernet status, and starts wpa_supplicant
|
||||||
// connectivity: when iface has no DHCP-assigned IPv4, start wpa_supplicant so
|
// when no DHCP-assigned address is present. When Ethernet is already
|
||||||
// the WiFi backend can take over.
|
// providing connectivity the WiFi backend is left alone — and crucially the
|
||||||
func ensureUplink(iface string) {
|
// caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would
|
||||||
has, err := hasDHCPAddress(iface)
|
// otherwise re-activate the daemon via dbus-activation.
|
||||||
|
func ensureUplink(iface string) *models.EthernetStatus {
|
||||||
|
eth, err := probeEthernet(iface)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Could not probe %s for DHCP address (%v); starting wpa_supplicant as fallback", iface, err)
|
log.Printf("Could not probe %s (%v); starting wpa_supplicant as fallback", iface, err)
|
||||||
} else if has {
|
eth = &models.EthernetStatus{Active: false, Interface: iface}
|
||||||
log.Printf("DHCP address present on %s, leaving wpa_supplicant alone", iface)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
log.Printf("No DHCP address on %s, starting wpa_supplicant", iface)
|
|
||||||
}
|
}
|
||||||
|
if eth.Active {
|
||||||
|
log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface)
|
||||||
|
return eth
|
||||||
|
}
|
||||||
|
log.Printf("No DHCP address on %s, starting wpa_supplicant", iface)
|
||||||
if out, err := exec.Command("service", "wpa_supplicant", "start").CombinedOutput(); err != nil {
|
if out, err := exec.Command("service", "wpa_supplicant", "start").CombinedOutput(); err != nil {
|
||||||
log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out)))
|
log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
|
return eth
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,14 @@ type HotspotConfig struct {
|
||||||
Channel int `json:"channel"`
|
Channel int `json:"channel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EthernetStatus represents the state of the wired uplink interface.
|
||||||
|
// Active is true when the interface holds a DHCP-assigned IPv4 address.
|
||||||
|
type EthernetStatus struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
IPv4 string `json:"ipv4,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// HotspotStatus represents detailed hotspot status
|
// HotspotStatus represents detailed hotspot status
|
||||||
type HotspotStatus struct {
|
type HotspotStatus struct {
|
||||||
State string `json:"state"` // ENABLED, DISABLED, etc.
|
State string `json:"state"` // ENABLED, DISABLED, etc.
|
||||||
|
|
@ -49,6 +57,7 @@ type SystemStatus struct {
|
||||||
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
|
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
|
||||||
ConnectedSSID string `json:"connectedSSID"`
|
ConnectedSSID string `json:"connectedSSID"`
|
||||||
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
|
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
|
||||||
|
EthernetStatus *EthernetStatus `json:"ethernetStatus,omitempty"` // Wired uplink state, when probed
|
||||||
ConnectedCount int `json:"connectedCount"`
|
ConnectedCount int `json:"connectedCount"`
|
||||||
DataUsage float64 `json:"dataUsage"`
|
DataUsage float64 `json:"dataUsage"`
|
||||||
Uptime int64 `json:"uptime"`
|
Uptime int64 `json:"uptime"`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package wifi
|
package wifi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -16,6 +17,13 @@ var (
|
||||||
wifiBroadcaster *WifiBroadcaster
|
wifiBroadcaster *WifiBroadcaster
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errWifiDisabled is returned by every wifi.* wrapper when no backend has
|
||||||
|
// been initialized. This happens when the application chose not to start
|
||||||
|
// wpa_supplicant because the Ethernet uplink is already providing
|
||||||
|
// connectivity — touching the D-Bus interface in that mode would re-activate
|
||||||
|
// the daemon via dbus-activation, defeating the intent.
|
||||||
|
var errWifiDisabled = errors.New("wifi backend disabled (Ethernet uplink active)")
|
||||||
|
|
||||||
// Initialize initializes the WiFi service with the specified backend
|
// Initialize initializes the WiFi service with the specified backend
|
||||||
func Initialize(interfaceName string, backendName string) error {
|
func Initialize(interfaceName string, backendName string) error {
|
||||||
// Create the appropriate backend using the factory
|
// Create the appropriate backend using the factory
|
||||||
|
|
@ -38,6 +46,9 @@ func Close() {
|
||||||
|
|
||||||
// GetCachedNetworks returns previously discovered networks without triggering a scan
|
// GetCachedNetworks returns previously discovered networks without triggering a scan
|
||||||
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
|
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return nil, errWifiDisabled
|
||||||
|
}
|
||||||
// Get ordered networks from backend
|
// Get ordered networks from backend
|
||||||
backendNetworks, err := wifiBackend.GetOrderedNetworks()
|
backendNetworks, err := wifiBackend.GetOrderedNetworks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -67,6 +78,9 @@ func GetCachedNetworks() ([]models.WiFiNetwork, error) {
|
||||||
|
|
||||||
// ScanNetworks scans for available WiFi networks
|
// ScanNetworks scans for available WiFi networks
|
||||||
func ScanNetworks() ([]models.WiFiNetwork, error) {
|
func ScanNetworks() ([]models.WiFiNetwork, error) {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return nil, errWifiDisabled
|
||||||
|
}
|
||||||
// Check if already scanning
|
// Check if already scanning
|
||||||
scanning, err := wifiBackend.IsScanning()
|
scanning, err := wifiBackend.IsScanning()
|
||||||
if err == nil && scanning {
|
if err == nil && scanning {
|
||||||
|
|
@ -114,6 +128,9 @@ func ScanNetworks() ([]models.WiFiNetwork, error) {
|
||||||
|
|
||||||
// Connect connects to a WiFi network
|
// Connect connects to a WiFi network
|
||||||
func Connect(ssid, password string) error {
|
func Connect(ssid, password string) error {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return errWifiDisabled
|
||||||
|
}
|
||||||
// Use backend to connect
|
// Use backend to connect
|
||||||
if err := wifiBackend.Connect(ssid, password); err != nil {
|
if err := wifiBackend.Connect(ssid, password); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -132,11 +149,17 @@ func Connect(ssid, password string) error {
|
||||||
|
|
||||||
// Disconnect disconnects from the current WiFi network
|
// Disconnect disconnects from the current WiFi network
|
||||||
func Disconnect() error {
|
func Disconnect() error {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return errWifiDisabled
|
||||||
|
}
|
||||||
return wifiBackend.Disconnect()
|
return wifiBackend.Disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConnected checks if WiFi is connected
|
// IsConnected checks if WiFi is connected
|
||||||
func IsConnected() bool {
|
func IsConnected() bool {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
state, err := wifiBackend.GetConnectionState()
|
state, err := wifiBackend.GetConnectionState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|
@ -146,11 +169,17 @@ func IsConnected() bool {
|
||||||
|
|
||||||
// GetConnectedSSID returns the SSID of the currently connected network
|
// GetConnectedSSID returns the SSID of the currently connected network
|
||||||
func GetConnectedSSID() string {
|
func GetConnectedSSID() string {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return wifiBackend.GetConnectedSSID()
|
return wifiBackend.GetConnectedSSID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConnectionState returns the current WiFi connection state
|
// GetConnectionState returns the current WiFi connection state
|
||||||
func GetConnectionState() string {
|
func GetConnectionState() string {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return string(backend.StateDisconnected)
|
||||||
|
}
|
||||||
state, err := wifiBackend.GetConnectionState()
|
state, err := wifiBackend.GetConnectionState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(backend.StateDisconnected)
|
return string(backend.StateDisconnected)
|
||||||
|
|
@ -160,6 +189,9 @@ func GetConnectionState() string {
|
||||||
|
|
||||||
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
|
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
|
||||||
func StartEventMonitoring() error {
|
func StartEventMonitoring() error {
|
||||||
|
if wifiBackend == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// Initialize broadcaster
|
// Initialize broadcaster
|
||||||
wifiBroadcaster = NewWifiBroadcaster()
|
wifiBroadcaster = NewWifiBroadcaster()
|
||||||
|
|
||||||
|
|
|
||||||
28
openapi.yaml
28
openapi.yaml
|
|
@ -369,6 +369,26 @@ components:
|
||||||
- ssid
|
- ssid
|
||||||
- password
|
- password
|
||||||
|
|
||||||
|
EthernetStatus:
|
||||||
|
type: object
|
||||||
|
description: State of the wired uplink interface (probed via ip(8))
|
||||||
|
properties:
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
description: True when the interface holds a DHCP-assigned IPv4
|
||||||
|
example: true
|
||||||
|
interface:
|
||||||
|
type: string
|
||||||
|
description: Probed interface name
|
||||||
|
example: "eth0"
|
||||||
|
ipv4:
|
||||||
|
type: string
|
||||||
|
description: DHCP-assigned IPv4 address (empty when no lease)
|
||||||
|
example: "192.168.1.42"
|
||||||
|
required:
|
||||||
|
- active
|
||||||
|
- interface
|
||||||
|
|
||||||
HotspotStatus:
|
HotspotStatus:
|
||||||
type: object
|
type: object
|
||||||
description: Detailed hotspot status from hostapd_cli
|
description: Detailed hotspot status from hostapd_cli
|
||||||
|
|
@ -480,13 +500,14 @@ components:
|
||||||
example: true
|
example: true
|
||||||
connectionState:
|
connectionState:
|
||||||
type: string
|
type: string
|
||||||
description: Current WiFi connection state
|
description: Current WiFi connection state ("disabled" when Ethernet uplink is active and the WiFi backend is intentionally not initialized)
|
||||||
enum:
|
enum:
|
||||||
- connected
|
- connected
|
||||||
- disconnected
|
- disconnected
|
||||||
- connecting
|
- connecting
|
||||||
- disconnecting
|
- disconnecting
|
||||||
- roaming
|
- roaming
|
||||||
|
- disabled
|
||||||
example: "connected"
|
example: "connected"
|
||||||
connectedSSID:
|
connectedSSID:
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -497,6 +518,11 @@ components:
|
||||||
- $ref: '#/components/schemas/HotspotStatus'
|
- $ref: '#/components/schemas/HotspotStatus'
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Detailed hotspot status (null if hotspot is not running)
|
description: Detailed hotspot status (null if hotspot is not running)
|
||||||
|
ethernetStatus:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/EthernetStatus'
|
||||||
|
nullable: true
|
||||||
|
description: Wired uplink state (null when not yet probed)
|
||||||
connectedCount:
|
connectedCount:
|
||||||
type: integer
|
type: integer
|
||||||
description: Number of devices connected to hotspot
|
description: Number of devices connected to hotspot
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue