Skip to content

Commit 7c0b905

Browse files
author
Bolyki György
committed
feat: add read-only data health inbox
1 parent 7054517 commit 7c0b905

27 files changed

Lines changed: 2550 additions & 120 deletions

File tree

lib/teslamate/data_health.ex

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
defmodule TeslaMate.DataHealth do
2+
@moduledoc """
3+
Read-only checks for TeslaMate data that may need manual review.
4+
5+
The report intentionally does not repair, close or delete records. It only
6+
surfaces drives and charging sessions that have remained open past a bounded
7+
age so a user can inspect them before choosing a maintenance action. Long-running
8+
does not by itself mean the data is corrupt.
9+
"""
10+
11+
import Ecto.Query, warn: false
12+
13+
alias TeslaMate.Log.{Car, ChargingProcess, Drive}
14+
alias TeslaMate.Repo
15+
16+
@default_open_after_seconds 2 * 24 * 60 * 60
17+
@default_limit 200
18+
19+
defmodule Finding do
20+
@moduledoc "A read-only data-health finding backed by database records."
21+
22+
@enforce_keys [
23+
:id,
24+
:code,
25+
:entity_type,
26+
:entity_id,
27+
:car_id,
28+
:car_name,
29+
:started_at
30+
]
31+
defstruct @enforce_keys
32+
end
33+
34+
defmodule Report do
35+
@moduledoc "A read-only data-health report."
36+
37+
@enforce_keys [
38+
:checked_at,
39+
:open_after_seconds,
40+
:findings,
41+
:truncated?,
42+
:read_only?
43+
]
44+
defstruct @enforce_keys
45+
end
46+
47+
@doc """
48+
Returns long-running open drives and charging sessions without changing the database.
49+
50+
Options are accepted to make the age boundary and result cap deterministic in
51+
tests. A record is long-running only when it started strictly before the cutoff.
52+
"""
53+
def report(opts \\ []) do
54+
checked_at = Keyword.get(opts, :now, DateTime.utc_now())
55+
56+
open_after_seconds =
57+
positive_integer_option!(opts, :open_after_seconds, @default_open_after_seconds)
58+
59+
limit = positive_integer_option!(opts, :limit, @default_limit)
60+
cutoff = DateTime.add(checked_at, -open_after_seconds, :second)
61+
62+
candidates =
63+
cutoff
64+
|> long_running_candidates(limit + 1)
65+
|> Enum.sort_by(fn candidate ->
66+
{
67+
DateTime.to_unix(candidate.started_at, :microsecond),
68+
candidate.entity_type,
69+
candidate.entity_id
70+
}
71+
end)
72+
73+
truncated? = length(candidates) > limit
74+
selected = Enum.take(candidates, limit)
75+
76+
findings =
77+
Enum.map(selected, fn candidate ->
78+
%Finding{
79+
id: "#{candidate.entity_type}:#{candidate.entity_id}",
80+
code: finding_code(candidate.entity_type),
81+
entity_type: candidate.entity_type,
82+
entity_id: candidate.entity_id,
83+
car_id: candidate.car_id,
84+
car_name: candidate.car_name,
85+
started_at: candidate.started_at
86+
}
87+
end)
88+
89+
%Report{
90+
checked_at: checked_at,
91+
open_after_seconds: open_after_seconds,
92+
findings: findings,
93+
truncated?: truncated?,
94+
read_only?: true
95+
}
96+
end
97+
98+
defp positive_integer_option!(opts, key, default) do
99+
case Keyword.get(opts, key, default) do
100+
value when is_integer(value) and value > 0 -> value
101+
value -> raise ArgumentError, "#{key} must be a positive integer, got: #{inspect(value)}"
102+
end
103+
end
104+
105+
defp long_running_candidates(cutoff, limit) do
106+
long_running_drives(cutoff, limit) ++ long_running_charging_processes(cutoff, limit)
107+
end
108+
109+
defp long_running_drives(cutoff, limit) do
110+
Drive
111+
|> join(:inner, [drive], car in Car, on: car.id == drive.car_id)
112+
|> where([drive], is_nil(drive.end_date) and drive.start_date < ^cutoff)
113+
|> order_by([drive], asc: drive.start_date, asc: drive.id)
114+
|> limit(^limit)
115+
|> select([drive, car], %{
116+
entity_type: :drive,
117+
entity_id: drive.id,
118+
car_id: car.id,
119+
car_name: car.name,
120+
started_at: drive.start_date
121+
})
122+
|> Repo.all()
123+
end
124+
125+
defp long_running_charging_processes(cutoff, limit) do
126+
ChargingProcess
127+
|> join(:inner, [charging_process], car in Car, on: car.id == charging_process.car_id)
128+
|> where(
129+
[charging_process],
130+
is_nil(charging_process.end_date) and charging_process.start_date < ^cutoff
131+
)
132+
|> order_by([charging_process], asc: charging_process.start_date, asc: charging_process.id)
133+
|> limit(^limit)
134+
|> select([charging_process, car], %{
135+
entity_type: :charging_process,
136+
entity_id: charging_process.id,
137+
car_id: car.id,
138+
car_name: car.name,
139+
started_at: charging_process.start_date
140+
})
141+
|> Repo.all()
142+
end
143+
144+
defp finding_code(:drive), do: :long_running_open_drive
145+
146+
defp finding_code(:charging_process),
147+
do: :long_running_open_charging_process
148+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule TeslaMateWeb.MaintenanceLive.Index do
2+
use TeslaMateWeb, :live_view
3+
4+
alias TeslaMate.DataHealth
5+
6+
on_mount {TeslaMateWeb.InitAssigns, :locale}
7+
8+
@impl true
9+
def mount(_params, _session, socket) do
10+
{:ok, load_report(socket)}
11+
end
12+
13+
@impl true
14+
def handle_event("refresh", _params, socket) do
15+
{:noreply, load_report(socket)}
16+
end
17+
18+
defp load_report(socket) do
19+
assign(socket,
20+
page_title: gettext("Maintenance"),
21+
report: DataHealth.report()
22+
)
23+
end
24+
25+
defp finding_title(%{entity_type: :drive, entity_id: id}) do
26+
gettext("Drive #%{id}", id: id)
27+
end
28+
29+
defp finding_title(%{entity_type: :charging_process, entity_id: id}) do
30+
gettext("Charging session #%{id}", id: id)
31+
end
32+
33+
defp finding_icon(%{entity_type: :drive}), do: "mdi-road-variant"
34+
defp finding_icon(%{entity_type: :charging_process}), do: "mdi-ev-station"
35+
36+
defp car_name(%{car_name: name}) when is_binary(name) and name != "", do: name
37+
defp car_name(%{car_id: id}), do: gettext("Car %{id}", id: id)
38+
39+
defp finding_dom_id(%{entity_type: type, entity_id: id}), do: "finding-#{type}-#{id}"
40+
41+
defp iso_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
42+
43+
defp open_hours(%{open_after_seconds: seconds}), do: div(seconds, 60 * 60)
44+
end
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<nav class="breadcrumb" aria-label="breadcrumbs">
2+
<ul>
3+
<li><.link navigate={~p"/"}><%= gettext("Home") %></.link></li>
4+
<li class="is-active">
5+
<.link navigate={~p"/maintenance"}><%= gettext("Maintenance") %></.link>
6+
</li>
7+
</ul>
8+
</nav>
9+
10+
<div id="maintenance-read-only" class="notification is-info is-light">
11+
<p class="has-text-weight-semibold">
12+
<%= gettext("Read-only. TeslaMate has not changed any data.") %>
13+
</p>
14+
<p>
15+
<%= gettext(
16+
"These checks only list drives and charging sessions that have stayed open for more than %{hours} hours.",
17+
hours: open_hours(@report)
18+
) %>
19+
</p>
20+
<p><%= gettext("A long-running session is not automatically corrupt.") %></p>
21+
</div>
22+
23+
<div id="maintenance-summary" class="box">
24+
<div class="level is-mobile">
25+
<div class="level-left">
26+
<div>
27+
<p class="heading"><%= gettext("Data health") %></p>
28+
<p class="title is-5">
29+
<%= if Enum.empty?(@report.findings) do %>
30+
<%= gettext("No long-running sessions") %>
31+
<% else %>
32+
<%= ngettext(
33+
"%{count} session needs review",
34+
"%{count} sessions need review",
35+
length(@report.findings),
36+
count: length(@report.findings)
37+
) %>
38+
<% end %>
39+
</p>
40+
</div>
41+
</div>
42+
<div class="level-right">
43+
<button
44+
id="maintenance-refresh"
45+
type="button"
46+
class="button is-info is-light is-small"
47+
phx-click="refresh"
48+
>
49+
<span class="icon"><span class="mdi mdi-refresh"></span></span>
50+
<span><%= gettext("Refresh") %></span>
51+
</button>
52+
</div>
53+
</div>
54+
55+
<p class="is-size-7 has-text-grey">
56+
<%= gettext("Checked") %>:
57+
<span
58+
id="maintenance-checked-at"
59+
data-date={iso_datetime(@report.checked_at)}
60+
phx-hook="LocalDateTime"
61+
>
62+
<%= iso_datetime(@report.checked_at) %>
63+
</span>
64+
</p>
65+
66+
<%= if @report.truncated? do %>
67+
<p id="maintenance-truncated" class="help is-warning mt-2">
68+
<%= gettext("Only the oldest findings are shown.") %>
69+
</p>
70+
<% end %>
71+
</div>
72+
73+
<%= if Enum.empty?(@report.findings) do %>
74+
<div id="maintenance-empty" class="notification is-success is-light">
75+
<%= gettext("No long-running open drives or charging sessions found.") %>
76+
</div>
77+
<% else %>
78+
<div id="maintenance-findings">
79+
<%= for finding <- @report.findings do %>
80+
<article
81+
id={finding_dom_id(finding)}
82+
class="box"
83+
data-finding-code={to_string(finding.code)}
84+
data-entity-id={finding.entity_id}
85+
>
86+
<div class="media">
87+
<div class="media-left">
88+
<span class="icon is-medium has-text-warning">
89+
<span class={["mdi", finding_icon(finding), "mdi-24px"]}></span>
90+
</span>
91+
</div>
92+
<div class="media-content">
93+
<p class="title is-5"><%= finding_title(finding) %></p>
94+
<p class="subtitle is-6"><%= car_name(finding) %></p>
95+
96+
<dl>
97+
<div class="columns is-mobile mb-0">
98+
<dt class="column is-one-third has-text-weight-medium">
99+
<%= gettext("Started") %>
100+
</dt>
101+
<dd class="column">
102+
<span
103+
id={"#{finding_dom_id(finding)}-started-at"}
104+
data-date={iso_datetime(finding.started_at)}
105+
phx-hook="LocalDateTime"
106+
>
107+
<%= iso_datetime(finding.started_at) %>
108+
</span>
109+
</dd>
110+
</div>
111+
</dl>
112+
</div>
113+
</div>
114+
</article>
115+
<% end %>
116+
</div>
117+
<% end %>
118+
119+
<div class="content has-text-centered mt-5">
120+
<p><%= gettext("Review each session before choosing a manual maintenance action.") %></p>
121+
<a
122+
href="https://docs.teslamate.org/docs/maintenance/manually_fixing_data"
123+
target="_blank"
124+
rel="noopener noreferrer"
125+
>
126+
<%= gettext("Open the manual maintenance guide") %>
127+
</a>
128+
</div>

lib/teslamate_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ defmodule TeslaMateWeb.Router do
3939
live_session :default do
4040
live "/sign_in", SignInLive.Index
4141
live "/settings", SettingsLive.Index
42+
live "/maintenance", MaintenanceLive.Index
4243
live "/geo-fences", GeoFenceLive.Index
4344
live "/geo-fences/new", GeoFenceLive.Form
4445
live "/geo-fences/:id/edit", GeoFenceLive.Form

lib/teslamate_web/templates/layout/root.html.heex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@
112112
>
113113
<span class="icon"><i class="mdi mdi-map-marker-radius"></i></span>&nbsp;<span><%= gettext("Geo-Fences") %></span>
114114
</.link>
115+
<.link
116+
navigate={Routes.live_path(@conn, TeslaMateWeb.MaintenanceLive.Index)}
117+
class="navbar-item mr-5"
118+
>
119+
<span class="icon"><i class="mdi mdi-wrench-outline"></i></span>&nbsp;<span><%= gettext("Maintenance") %></span>
120+
</.link>
115121
<.link
116122
navigate={Routes.live_path(@conn, TeslaMateWeb.SettingsLive.Index)}
117123
class="navbar-item mr-5"

0 commit comments

Comments
 (0)