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
23 changes: 23 additions & 0 deletions app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,26 @@ query GetNetworkInterfaces {
}
}
}

# Capability detection (ADR-0047). Two operations on purpose: `me` is non-null, so for a key without
# ME:READ_ANY a FORBIDDEN on `me` would null the whole response and lose the version as well.
query GetApiVersion {
info {
versions {
core {
unraid
api
}
}
}
}

query GetKeyAccess {
me {
roles
permissions {
resource
actions
}
}
}
67 changes: 67 additions & 0 deletions app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type Query {
plugins: [Plugin!]!
pluginInstallOperations: [PluginInstallOperation!]!
display: InfoDisplay
me: UserAccount!
}

# ── Display (global temperature thresholds) ──────────────────────────
Expand Down Expand Up @@ -265,6 +266,7 @@ type InfoVersions {

type CoreVersions {
unraid: String
api: String
kernel: String
}

Expand Down Expand Up @@ -565,3 +567,68 @@ type DockerContainerStats {
netIO: String!
blockIO: String!
}

# ── API key access (capability detection, ADR-0047) ──────────────────
# Subset of the live Unraid `me` query. `me` describes the API key, not a
# signed-in user. Enum values are the Unraid 4.37.11 schema; a value a newer
# server adds arrives as UNKNOWN__ (Apollo keeps no raw name), which NOVA treats as unknown.

type UserAccount {
roles: [Role!]!
permissions: [Permission!]
}

type Permission {
resource: Resource!
actions: [AuthAction!]!
}

enum Role {
ADMIN
CONNECT
GUEST
VIEWER
}

enum AuthAction {
CREATE_ANY
CREATE_OWN
READ_ANY
READ_OWN
UPDATE_ANY
UPDATE_OWN
DELETE_ANY
DELETE_OWN
}

enum Resource {
ACTIVATION_CODE
API_KEY
ARRAY
CLOUD
CONFIG
CONNECT
CONNECT__REMOTE_ACCESS
CUSTOMIZATIONS
DASHBOARD
DISK
DISPLAY
DOCKER
FLASH
INFO
LOGS
ME
NETWORK
NOTIFICATIONS
ONLINE
OS
OWNER
PERMISSION
REGISTRATION
SERVERS
SERVICES
SHARE
VARS
VMS
WELCOME
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package io.github.nofuturekid.nova.data.alerts

import io.github.nofuturekid.nova.data.model.ArrayInfo
import io.github.nofuturekid.nova.data.model.ArrayState
import io.github.nofuturekid.nova.data.model.AttentionSeverity
import io.github.nofuturekid.nova.data.model.AttentionSnapshot
import io.github.nofuturekid.nova.data.model.AttentionTarget
import io.github.nofuturekid.nova.data.model.NotifImportance
import io.github.nofuturekid.nova.data.model.Notifications
import io.github.nofuturekid.nova.data.model.UnraidNotification
import io.github.nofuturekid.nova.data.model.attentionItems

/**
* The array conditions that become alerts. Unraid itself never notifies about these
* (it stays silent about disks while the array is stopped), so NOVA raises them.
*
* Deliberately not widened to disk temperature, disk errors or parity items: Unraid
* already sends those as notifications, and raising them again would post every
* problem twice. A test fails if this set grows without that decision.
*/
val STATE_ALERT_KEYS: Set<String> = setOf("array-stopped", "array-degraded")

/** At most this many alerts are posted per server and check; the rest are folded into a summary. */
const val MAX_ALERTS_PER_CHECK = 5

/** What the user wants to hear about. Critical things are always included. */
enum class AlertLevel { AlertsOnly, AlertsAndWarnings }

/** The server an evaluation is for: the stable id keys the state, the name is for display. */
data class ServerRef(val id: String, val name: String)

/** Where an [Alert] comes from. */
enum class AlertSource { Notification, ArrayCondition }

/**
* One alert to post now. [key] is the notification id or the condition key; it is
* stable, so the poster can derive a stable Android notification id from server id + key.
*/
data class Alert(
val key: String,
val source: AlertSource,
val severity: AttentionSeverity,
val title: String,
val text: String,
/** The server's ISO timestamp for a notification; null for a condition. */
val timestamp: String?,
val target: AttentionTarget,
)

/**
* Result of one evaluation. [toPost] holds at most [MAX_ALERTS_PER_CHECK] alerts, most
* urgent first; [foldedCount] more qualified but are only worth a summary line
* ([foldedSummaryText]). [nextState] must be stored whatever was posted: ids that were
* folded or filtered out count as reported.
*/
data class AlertEvaluation(
val server: ServerRef,
val toPost: List<Alert>,
val foldedCount: Int,
val nextState: ServerAlertState,
)

/** The summary line for alerts that did not get their own notification, e.g. "and 3 more on Tower". */
fun foldedSummaryText(serverName: String, count: Int): String = "and $count more on $serverName"

/**
* Decides which alerts to post after one check of one server, and what to remember.
* Pure: no Android, no clock, no I/O.
*
* [notifications] and [array] are what the check returned; null means "not loaded this
* time" (server unreachable, query failed). A null half changes nothing and posts nothing,
* so alerts created during an outage are posted once the server answers again, and nothing
* is posted twice. [array] with state Offline (not known) counts as not loaded.
*
* Notifications: every unread notification of importance alert or warning whose id is not
* remembered is new. The very first successful read only remembers the ids, so switching
* alerts on does not dump everything already unread on the user. An id is reported once,
* even if it leaves the unread list and returns. A later notification with the same text
* has a new id (Unraid builds it from the time) and is reported, as it should be.
*
* Array conditions: raised when they begin, not while they last, and again only after they
* were gone at an in-between read. The rules are those of [attentionItems], wrapped and
* restricted to [STATE_ALERT_KEYS], so the wording is the Overview card's own.
*
* [level]: [AlertLevel.AlertsOnly] leaves out warning-level notifications; the two array
* conditions are always posted. Notifications it filters out are still remembered, so switching
* to [AlertLevel.AlertsAndWarnings] later does not post old ones.
*/
fun evaluateAlerts(
server: ServerRef,
previous: ServerAlertState,
notifications: Notifications?,
array: ArrayInfo?,
level: AlertLevel,
): AlertEvaluation {
val (conditionAlerts, afterConditions) = evaluateConditions(previous, array)
val (notificationAlerts, next) = evaluateNotifications(afterConditions, notifications, level)
// sortedByDescending is stable: equal severity keeps conditions before notifications.
val ordered = (conditionAlerts + notificationAlerts).sortedByDescending { it.severity }
return AlertEvaluation(
server = server,
toPost = ordered.take(MAX_ALERTS_PER_CHECK),
foldedCount = (ordered.size - MAX_ALERTS_PER_CHECK).coerceAtLeast(0),
nextState = next,
)
}

private fun evaluateNotifications(
previous: ServerAlertState,
notifications: Notifications?,
level: AlertLevel,
): Pair<List<Alert>, ServerAlertState> {
if (notifications == null) return emptyList<Alert>() to previous
val current = notifications.items.distinctBy { it.id }
val remembered = previous.seenNotificationIds.toSet()
val alerts = if (!previous.notificationsBaselined) {
emptyList()
} else {
current.filter { it.id !in remembered }.mapNotNull { it.toAlert(level) }
}
// Ids still on the server go last, so only ids that are gone are forgotten first.
val currentIds = current.map { it.id }
val stillListed = currentIds.toSet()
val seen = (previous.seenNotificationIds.filter { it !in stillListed } + currentIds)
.takeLast(MAX_SEEN_NOTIFICATION_IDS)
return alerts to previous.copy(notificationsBaselined = true, seenNotificationIds = seen)
}

private fun evaluateConditions(
previous: ServerAlertState,
array: ArrayInfo?,
): Pair<List<Alert>, ServerAlertState> {
if (array == null || array.state == ArrayState.Offline) return emptyList<Alert>() to previous
val current = attentionItems(AttentionSnapshot(array, notifications = null, thresholds = null))
.filter { it.key in STATE_ALERT_KEYS }
val alerts = if (!previous.conditionsBaselined) {
emptyList()
} else {
current
.filter { it.key !in previous.openConditions }
.map {
Alert(it.key, AlertSource.ArrayCondition, it.severity, it.title, it.detail, timestamp = null, target = it.target)
}
}
return alerts to previous.copy(conditionsBaselined = true, openConditions = current.map { it.key }.toSet())
}

private fun UnraidNotification.toAlert(level: AlertLevel): Alert? {
val severity = when (importance) {
NotifImportance.Alert -> AttentionSeverity.Critical
NotifImportance.Warning -> AttentionSeverity.Warning
NotifImportance.Info -> return null
}
if (!level.allows(severity)) return null
return Alert(
key = id,
source = AlertSource.Notification,
severity = severity,
title = subject.ifBlank { title }.ifBlank { "Server notification" },
text = description,
timestamp = timestamp,
target = AttentionTarget.Notifications,
)
}

private fun AlertLevel.allows(severity: AttentionSeverity): Boolean = when (this) {
AlertLevel.AlertsOnly -> severity == AttentionSeverity.Critical
AlertLevel.AlertsAndWarnings -> severity >= AttentionSeverity.Warning
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.github.nofuturekid.nova.data.alerts

import kotlinx.serialization.Serializable

/** How many notification ids one server remembers. Oldest forgotten first beyond this. */
const val MAX_SEEN_NOTIFICATION_IDS = 300

/**
* What NOVA remembers about one server so that an alert is raised once, not at every check.
* Persisted per server (see [AlertStateStore]); [evaluateAlerts] produces the next value.
*
* The two halves are independent because the two reads can fail independently: a failed
* read leaves its half exactly as it was.
*/
@Serializable
data class ServerAlertState(
/** False until one notification read succeeded: that read only records, it posts nothing. */
val notificationsBaselined: Boolean = false,
/** Ids already reported or deliberately skipped, oldest first, at most [MAX_SEEN_NOTIFICATION_IDS]. */
val seenNotificationIds: List<String> = emptyList(),
/** False until one array read succeeded: that read only records, it posts nothing. */
val conditionsBaselined: Boolean = false,
/** Array conditions that held at the last successful read, a subset of [STATE_ALERT_KEYS]. */
val openConditions: Set<String> = emptySet(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.github.nofuturekid.nova.data.alerts

import io.github.nofuturekid.nova.data.local.SettingsStore
import javax.inject.Inject
import javax.inject.Singleton

/**
* The remembered [ServerAlertState] per server, keyed by the server's stable id.
* A server without an entry has the empty state, which means "not baselined yet".
*/
interface AlertStateStore {
suspend fun get(serverId: String): ServerAlertState
suspend fun put(serverId: String, state: ServerAlertState)

/** Forgets one server, for example when its entry is deleted. */
suspend fun remove(serverId: String)

/** Forgets every server, for example when alerts are switched off, so the next start baselines afresh. */
suspend fun clear()
}

/** Persists the state in the app's DataStore, so it survives process death. */
@Singleton
class SettingsAlertStateStore @Inject constructor(
private val store: SettingsStore,
) : AlertStateStore {
override suspend fun get(serverId: String): ServerAlertState = store.alertStateFor(serverId)
override suspend fun put(serverId: String, state: ServerAlertState) = store.setAlertState(serverId, state)
override suspend fun remove(serverId: String) = store.clearAlertState(serverId)
override suspend fun clear() = store.clearAllAlertStates()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.github.nofuturekid.nova.data.alerts

import io.github.nofuturekid.nova.data.model.ArrayInfo
import io.github.nofuturekid.nova.data.model.Notifications
import io.github.nofuturekid.nova.data.model.Server

/**
* The one-shot seam: fetch what one check needs from one server, once, and say how it went.
* The background worker will call it per server; a home-screen widget can reuse it.
*
* Only the interface exists so far. The real implementation (two existing read-only queries,
* fail-closed certificate trust, no key reset, no prompts) belongs to the worker change.
* [pin] is the pinned local certificate fingerprint of [server], if any.
*/
interface ServerProbe {
suspend fun probe(server: Server, pin: String?): ProbeResult
}

sealed interface ProbeResult {
/** The server answered. Each half is null when its own query failed. */
data class Ok(val notifications: Notifications?, val array: ArrayInfo?) : ProbeResult
data object Unreachable : ProbeResult
data object CertificateProblem : ProbeResult
data object KeyMissing : ProbeResult
data object KeyUndecryptable : ProbeResult
data class Rejected(val message: String) : ProbeResult
}

/**
* What [evaluateAlerts] gets for a result. Anything but [ProbeResult.Ok] loaded nothing,
* so it changes no state and posts nothing: failures are never alerts.
*/
val ProbeResult.loadedNotifications: Notifications? get() = (this as? ProbeResult.Ok)?.notifications

/** See [loadedNotifications]. */
val ProbeResult.loadedArray: ArrayInfo? get() = (this as? ProbeResult.Ok)?.array
Loading
Loading