mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-21 08:14:00 +08:00
Supernova
This commit is contained in:
@@ -312,6 +312,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"ForceStandstill", {PERSISTENT, BOOL, "0", "0", 2}},
|
||||
{"ForceTorqueController", {PERSISTENT, BOOL, "0", "0", 3}},
|
||||
{"FPSCounter", {PERSISTENT, BOOL, "1", "0", 3}},
|
||||
{"GalaxyDashboardStats", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}},
|
||||
{"StarPilotApiToken", {PERSISTENT | DONT_LOG, STRING, "", "", 0}},
|
||||
{"StarPilotCarParams", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES, "", ""}},
|
||||
{"StarPilotCarParamsPersistent", {PERSISTENT, BYTES, "", ""}},
|
||||
|
||||
@@ -1,84 +1,698 @@
|
||||
.disk .bar {
|
||||
background-color: var(--main-bg);
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.disk .progress {
|
||||
background: linear-gradient(to right, #5ec8c8 0%, #8b6cc5 60%, #e05577 85%, #c04466 100%);
|
||||
border-radius: var(--border-radius-md);
|
||||
height: var(--padding-base);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.diskUsage,
|
||||
.drivingStats,
|
||||
.softwareInfo {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
max-width: 80%;
|
||||
padding: var(--border-radius-xl);
|
||||
}
|
||||
|
||||
.diskUsage .disk h4,
|
||||
.diskUsage .disk p,
|
||||
.drivingStat div p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.diskUsage p,
|
||||
.softwareGrid p {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.softwareGrid p {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drivingStat {
|
||||
.dashboard {
|
||||
--dashboard-accent: #8b6cc5;
|
||||
--dashboard-accent-2: #5ec8c8;
|
||||
--dashboard-border: rgba(139, 108, 197, 0.28);
|
||||
--dashboard-card: #0e1020;
|
||||
--dashboard-card-2: #111525;
|
||||
--dashboard-muted: #c4bfdc;
|
||||
--dashboard-strong: #faf8ff;
|
||||
--dashboard-track: rgba(139, 108, 197, 0.16);
|
||||
box-sizing: border-box;
|
||||
color: var(--dashboard-strong);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
margin-bottom: var(--border-radius-xl);
|
||||
gap: 1rem;
|
||||
margin: 0 auto;
|
||||
max-width: 1280px;
|
||||
min-width: 0;
|
||||
padding: 2rem 2rem 3rem 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drivingStat div p:first-of-type {
|
||||
font-size: var(--font-size-xl);
|
||||
.dashboard *,
|
||||
.dashboard *::before,
|
||||
.dashboard *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.drivingStat h2 {
|
||||
font-size: var(--font-size-base);
|
||||
grid-column: span 3;
|
||||
.dashboard h1,
|
||||
.dashboard h2,
|
||||
.dashboard h3,
|
||||
.dashboard p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drivingStat:last-of-type h2 {
|
||||
color: var(--success-fg);
|
||||
.dashboard h1 {
|
||||
color: var(--dashboard-strong);
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.softwareGrid {
|
||||
.dashboard h2 {
|
||||
color: var(--dashboard-strong);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.dashboard h3 {
|
||||
color: var(--dashboard-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: linear-gradient(180deg, var(--dashboard-card), var(--dashboard-card-2));
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
|
||||
color: var(--dashboard-strong);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
align-items: center;
|
||||
color: var(--dashboard-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-header strong,
|
||||
.dashboard-status-dot,
|
||||
.dashboard-card-kicker,
|
||||
.dashboard-drive-metrics strong:last-child,
|
||||
.dashboard-key-values strong,
|
||||
.dashboard-engaged-cell strong {
|
||||
color: var(--dashboard-accent);
|
||||
}
|
||||
|
||||
.dashboard-status-dot,
|
||||
.dashboard-card-kicker span,
|
||||
.dashboard-section-label span {
|
||||
background: var(--dashboard-accent);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
height: 0.5rem;
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-refresh {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 8px;
|
||||
color: var(--dashboard-strong);
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
gap: 0.45rem;
|
||||
justify-content: center;
|
||||
min-height: 2.45rem;
|
||||
padding: 0 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-refresh:hover {
|
||||
background: rgba(139, 108, 197, 0.16);
|
||||
border-color: rgba(139, 108, 197, 0.42);
|
||||
}
|
||||
|
||||
.dashboard-last-drive {
|
||||
display: grid;
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.9rem;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.diskUsage:hover,
|
||||
.drivingStats:hover,
|
||||
.softwareInfo:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: var(--hover-scale-sm);
|
||||
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
.dashboard-card-kicker,
|
||||
.dashboard-section-label {
|
||||
align-items: center;
|
||||
color: var(--dashboard-muted);
|
||||
display: inline-flex;
|
||||
font-size: 0.86rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
.softwareGrid {
|
||||
display: grid;
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
.dashboard-card-kicker {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.65rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.dashboard-drive-date,
|
||||
.dashboard-muted,
|
||||
.dashboard-empty,
|
||||
.dashboard-software-list span,
|
||||
.dashboard-key-values span,
|
||||
.dashboard-record-row p,
|
||||
.dashboard-record-row small,
|
||||
.dashboard-model-row small,
|
||||
.dashboard-drive-main span,
|
||||
.dashboard-drive-details,
|
||||
.dashboard-attention {
|
||||
color: var(--dashboard-muted);
|
||||
}
|
||||
|
||||
.dashboard-drive-metrics,
|
||||
.dashboard-stat-row,
|
||||
.dashboard-week-metrics {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-stat-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-drive-metrics div,
|
||||
.dashboard-stat-row div,
|
||||
.dashboard-week-metrics div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-drive-metrics strong,
|
||||
.dashboard-stat-row strong,
|
||||
.dashboard-week-metrics strong {
|
||||
color: var(--dashboard-strong);
|
||||
display: block;
|
||||
font-size: 1.18rem;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.dashboard-stat-row strong {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-metrics span,
|
||||
.dashboard-stat-row span,
|
||||
.dashboard-week-metrics span {
|
||||
color: var(--dashboard-muted);
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.28rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-drive-footer span {
|
||||
align-items: center;
|
||||
color: var(--dashboard-muted);
|
||||
display: inline-flex;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-section-label {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.dashboard-summary-grid,
|
||||
.dashboard-device-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-device-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
}
|
||||
|
||||
.dashboard-summary-card h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-two-column {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-model-storage {
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.4fr);
|
||||
}
|
||||
|
||||
.dashboard-week {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.dashboard-week-top,
|
||||
.dashboard-model-layout {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-donut,
|
||||
.dashboard-favorite-donut {
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
justify-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-donut {
|
||||
--value: 0;
|
||||
background: conic-gradient(var(--dashboard-accent) calc(var(--value) * 1%), var(--dashboard-track) 0);
|
||||
height: 116px;
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.dashboard-donut::after,
|
||||
.dashboard-favorite-donut::after {
|
||||
background: var(--dashboard-card);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
inset: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dashboard-donut strong,
|
||||
.dashboard-donut span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dashboard-donut strong {
|
||||
color: var(--dashboard-strong);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-donut span {
|
||||
color: var(--dashboard-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.dashboard-week-metrics {
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-bars {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
height: 120px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-day {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100%;
|
||||
justify-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-day-bar {
|
||||
align-self: end;
|
||||
background: linear-gradient(180deg, var(--dashboard-accent), var(--dashboard-accent-2));
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 4px;
|
||||
width: min(100%, 54px);
|
||||
}
|
||||
|
||||
.dashboard-day span {
|
||||
color: var(--dashboard-muted);
|
||||
font-size: 0.74rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-records {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dashboard-records h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-record-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0;
|
||||
}
|
||||
|
||||
.dashboard-record-row:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.dashboard-record-row > span {
|
||||
align-items: center;
|
||||
background: rgba(139, 108, 197, 0.16);
|
||||
border-radius: 8px;
|
||||
color: var(--dashboard-accent);
|
||||
display: inline-flex;
|
||||
height: 2.1rem;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
}
|
||||
|
||||
.dashboard-record-row strong {
|
||||
color: var(--dashboard-strong);
|
||||
display: inline;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-record-row small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-recent {
|
||||
padding-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.dashboard-recent h2 {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(170px, 1.3fr) minmax(230px, 1fr) minmax(210px, 1fr) minmax(180px, 0.9fr);
|
||||
min-width: 0;
|
||||
padding: 0.9rem 0;
|
||||
}
|
||||
|
||||
.dashboard-drive-row:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.dashboard-drive-main,
|
||||
.dashboard-drive-details,
|
||||
.dashboard-attention,
|
||||
.dashboard-engaged-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-drive-main strong,
|
||||
.dashboard-drive-main span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-drive-main strong {
|
||||
color: var(--dashboard-strong);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-main span {
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-details,
|
||||
.dashboard-attention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
gap: 0.45rem 0.8rem;
|
||||
}
|
||||
|
||||
.dashboard-engaged-cell {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.dashboard-mini-bar,
|
||||
.dashboard-storage-track {
|
||||
background: var(--dashboard-track);
|
||||
border-radius: 999px;
|
||||
height: 0.45rem;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-mini-bar span,
|
||||
.dashboard-storage-track span {
|
||||
background: linear-gradient(90deg, var(--dashboard-accent), var(--dashboard-accent-2));
|
||||
border-radius: inherit;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-model-layout {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-favorite-donut {
|
||||
background: conic-gradient(var(--dashboard-track) 0 100%);
|
||||
height: 132px;
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.dashboard-model-list {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-model-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-model-row strong,
|
||||
.dashboard-model-row small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-swatch {
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
height: 0.7rem;
|
||||
width: 0.7rem;
|
||||
}
|
||||
|
||||
.dashboard-device-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-storage-track {
|
||||
border-radius: 4px;
|
||||
height: 0.72rem;
|
||||
}
|
||||
|
||||
.dashboard-key-values,
|
||||
.dashboard-software-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.dashboard-key-values div,
|
||||
.dashboard-software-list div {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0;
|
||||
}
|
||||
|
||||
.dashboard-key-values div:first-child,
|
||||
.dashboard-software-list div:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.dashboard-key-values strong,
|
||||
.dashboard-software-list strong {
|
||||
color: var(--dashboard-strong);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dashboard-software-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-software-list div {
|
||||
align-items: start;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-software-list strong {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dashboard-software-list a {
|
||||
color: inherit;
|
||||
text-decoration-color: rgba(139, 108, 197, 0.65);
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
.dashboard-software-list a:hover {
|
||||
color: var(--dashboard-accent-2);
|
||||
}
|
||||
|
||||
.dashboard-empty,
|
||||
.dashboard-loading,
|
||||
.dashboard-error {
|
||||
color: var(--dashboard-muted);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.dashboard-error {
|
||||
color: var(--danger-fg);
|
||||
}
|
||||
|
||||
.dashboard-narrow {
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1180px) {
|
||||
.dashboard {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-row {
|
||||
grid-template-columns: minmax(170px, 1fr) minmax(210px, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 900px) {
|
||||
.dashboard-summary-grid,
|
||||
.dashboard-two-column,
|
||||
.dashboard-model-storage,
|
||||
.dashboard-device-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.dashboard {
|
||||
gap: 0.85rem;
|
||||
padding: 1.25rem 0 2rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard h1 {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-summary-grid,
|
||||
.dashboard-drive-metrics,
|
||||
.dashboard-stat-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-drive-row {
|
||||
align-items: stretch;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-drive-row:first-of-type {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-drive-main strong,
|
||||
.dashboard-drive-main span {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 560px) {
|
||||
.dashboard-header,
|
||||
.dashboard-week-top,
|
||||
.dashboard-model-layout {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-donut,
|
||||
.dashboard-favorite-donut {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.dashboard-refresh {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-drive-metrics,
|
||||
.dashboard-stat-row,
|
||||
.dashboard-week-metrics,
|
||||
.dashboard-summary-grid,
|
||||
.dashboard-software-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-bars {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.dashboard-day-bar {
|
||||
width: min(100%, 36px);
|
||||
}
|
||||
|
||||
.dashboard-key-values div,
|
||||
.dashboard-software-list div,
|
||||
.dashboard-engaged-cell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-key-values strong,
|
||||
.dashboard-software-list strong {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
|
||||
const HOME_STATE = {
|
||||
status: "loading", // loading | ready | error
|
||||
status: "loading",
|
||||
data: null,
|
||||
unit: "miles",
|
||||
error: "",
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
const FAVORITE_COLORS = ["#5ec8c8", "#8b6cc5", "#d4a060", "#e05577", "#6cc56e", "#8aa3ff"];
|
||||
const TOP_MODEL_LIMIT = 3;
|
||||
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
||||
@@ -21,101 +24,443 @@ function withTimeout(promise, timeoutMs, label) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
const n = Number(value || 0);
|
||||
return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0";
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function renderDiskUsageSection(state) {
|
||||
const { data } = state;
|
||||
function safeUrl(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return text.startsWith("https://github.com/") ? text : "";
|
||||
}
|
||||
|
||||
function numberValue(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
return Math.round(numberValue(value)).toLocaleString("en-US", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function formatOneDecimal(value) {
|
||||
return numberValue(value).toLocaleString("en-US", { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.max(0, Math.min(100, Math.round(numberValue(value))))}%`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const total = Math.max(0, Math.round(numberValue(seconds)));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "No drives yet";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Math.max(0, numberValue(bytes));
|
||||
if (value >= 2 ** 30) return `${formatOneDecimal(value / (2 ** 30))} GB`;
|
||||
if (value >= 2 ** 20) return `${formatOneDecimal(value / (2 ** 20))} MB`;
|
||||
return `${formatInt(value / 1024)} KB`;
|
||||
}
|
||||
|
||||
function statBlock(title, stats = {}, unit) {
|
||||
return `
|
||||
<section class="dashboard-card dashboard-summary-card">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<div class="dashboard-stat-row">
|
||||
<div><strong>${formatInt(stats.drives)}</strong><span>drives</span></div>
|
||||
<div><strong>${formatOneDecimal(stats.distance)}</strong><span>${escapeHtml(stats.unit || unit)}</span></div>
|
||||
<div><strong>${formatOneDecimal(stats.hours)}</strong><span>hours</span></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function fallbackDashboard(data, unit) {
|
||||
const disk = Array.isArray(data?.diskUsage) ? data.diskUsage[0] : {};
|
||||
const usedText = String(disk?.used || "0 GB");
|
||||
const sizeText = String(disk?.size || "0 GB");
|
||||
return {
|
||||
lastDrive: {
|
||||
date: "",
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
avgSpeed: 0,
|
||||
engagedPercent: 0,
|
||||
model: "Unknown model",
|
||||
segmentCount: 0,
|
||||
distractedMoments: 0,
|
||||
unresponsiveMoments: 0,
|
||||
distanceUnit: unit,
|
||||
speedUnit: unit === "kilometers" ? "kph" : "mph",
|
||||
},
|
||||
recentDrives: [],
|
||||
week: {
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
hours: 0,
|
||||
drives: 0,
|
||||
engagedPercent: 0,
|
||||
dailyDistance: [],
|
||||
distanceUnit: unit,
|
||||
},
|
||||
records: {
|
||||
longestDrive: { value: "0", detail: unit },
|
||||
mostEngagedDay: { value: "0%", detail: "No drives" },
|
||||
bestWeek: { value: "0", detail: unit },
|
||||
highestStreak: { value: "0 days", detail: "No drives" },
|
||||
longestUndistractedDrive: { value: "0.0 hours", detail: "No clean drives" },
|
||||
cleanDriveStreak: { value: "0 drives", detail: "No clean drives" },
|
||||
},
|
||||
device: { status: "Parked", online: true, uptimeSeconds: null, cpuTempC: null },
|
||||
storage: {
|
||||
freeBytes: 0,
|
||||
usedBytes: 0,
|
||||
totalBytes: 0,
|
||||
usedPercent: Number.parseFloat(disk?.usedPercentage) || 0,
|
||||
legacyText: `${usedText} used of ${sizeText}`,
|
||||
segmentCounts: { standard: 0, highResolution: 0, alternate: 0 },
|
||||
},
|
||||
favoriteModels: [],
|
||||
};
|
||||
}
|
||||
|
||||
function renderLastDrive(drive) {
|
||||
return `
|
||||
<section class="dashboard-card dashboard-last-drive">
|
||||
<div class="dashboard-card-kicker"><span></span>Last drive</div>
|
||||
<div class="dashboard-drive-date">${escapeHtml(formatDate(drive.date))}</div>
|
||||
<div class="dashboard-drive-metrics">
|
||||
<div><strong>${formatOneDecimal(drive.distance)}</strong><span>${escapeHtml(drive.distanceUnit || "miles")}</span></div>
|
||||
<div><strong>${formatDuration(drive.duration)}</strong><span>duration</span></div>
|
||||
<div><strong>${formatInt(drive.avgSpeed)}</strong><span>${escapeHtml(drive.speedUnit || "mph")} avg</span></div>
|
||||
<div><strong>${formatPercent(drive.engagedPercent)}</strong><span>engaged</span></div>
|
||||
</div>
|
||||
<div class="dashboard-drive-footer">
|
||||
<span><i class="bi bi-cpu"></i>${escapeHtml(drive.model || "Unknown model")}</span>
|
||||
<span><i class="bi bi-eye"></i>${formatInt(drive.distractedMoments)} distracted</span>
|
||||
<span><i class="bi bi-exclamation-triangle"></i>${formatInt(drive.unresponsiveMoments)} unresponsive</span>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWeekChart(week) {
|
||||
const days = Array.isArray(week.dailyDistance) && week.dailyDistance.length
|
||||
? week.dailyDistance
|
||||
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(label => ({ label, distance: 0 }));
|
||||
const maxDistance = Math.max(1, ...days.map(day => numberValue(day.distance)));
|
||||
const bars = days.map(day => {
|
||||
const height = Math.max(4, (numberValue(day.distance) / maxDistance) * 100);
|
||||
return `
|
||||
<div class="dashboard-day">
|
||||
<div class="dashboard-day-bar" style="height:${height}%"></div>
|
||||
<span>${escapeHtml(day.label)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<section class="dashboard-card dashboard-week">
|
||||
<h2>This week</h2>
|
||||
<div class="dashboard-week-top">
|
||||
<div class="dashboard-donut" style="--value:${Math.max(0, Math.min(100, numberValue(week.engagedPercent)))}">
|
||||
<strong>${formatPercent(week.engagedPercent)}</strong>
|
||||
<span>engaged</span>
|
||||
</div>
|
||||
<div class="dashboard-week-metrics">
|
||||
<div><strong>${formatOneDecimal(week.distance)}</strong><span>${escapeHtml(week.distanceUnit || "miles")}</span></div>
|
||||
<div><strong>${formatOneDecimal(week.hours)}</strong><span>hours</span></div>
|
||||
<div><strong>${formatInt(week.drives)}</strong><span>drives</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Distance per day</h3>
|
||||
<div class="dashboard-bars">${bars}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function recordRow(icon, title, record) {
|
||||
return `
|
||||
<div class="dashboard-record-row">
|
||||
<span><i class="bi ${icon}"></i></span>
|
||||
<div>
|
||||
<p>${escapeHtml(title)}</p>
|
||||
<strong>${escapeHtml(record?.value ?? "0")}</strong>
|
||||
<small>${escapeHtml(record?.detail ?? "")}</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecords(records) {
|
||||
return `
|
||||
<section class="dashboard-card dashboard-records">
|
||||
<h2>Personal records</h2>
|
||||
${recordRow("bi-arrow-right", "Longest drive", records.longestDrive)}
|
||||
${recordRow("bi-check2-circle", "Most-engaged day", records.mostEngagedDay)}
|
||||
${recordRow("bi-graph-up-arrow", "Best week", records.bestWeek)}
|
||||
${recordRow("bi-lightning-charge", "Highest streak", records.highestStreak)}
|
||||
${recordRow("bi-shield-check", "Longest undistracted drive", records.longestUndistractedDrive)}
|
||||
${recordRow("bi-stars", "Clean-drive streak", records.cleanDriveStreak)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecentDrives(drives) {
|
||||
if (!Array.isArray(drives) || drives.length === 0) {
|
||||
return `
|
||||
<section class="dashboard-card dashboard-recent">
|
||||
<h2>Recent drives</h2>
|
||||
<div class="dashboard-empty">No local drives found yet.</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const rows = drives.map(drive => `
|
||||
<div class="dashboard-drive-row">
|
||||
<div class="dashboard-drive-main">
|
||||
<strong>${escapeHtml(formatDate(drive.date))}</strong>
|
||||
<span>${escapeHtml(drive.model || "Unknown model")}</span>
|
||||
</div>
|
||||
<div class="dashboard-drive-details">
|
||||
<span>${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")}</span>
|
||||
<span>${formatDuration(drive.duration)}</span>
|
||||
<span>${formatInt(drive.segmentCount)} segments</span>
|
||||
</div>
|
||||
<div class="dashboard-attention">
|
||||
<span>${formatInt(drive.distractedMoments)} distracted</span>
|
||||
<span>${formatInt(drive.unresponsiveMoments)} unresponsive</span>
|
||||
</div>
|
||||
<div class="dashboard-engaged-cell">
|
||||
<div class="dashboard-mini-bar"><span style="width:${Math.max(0, Math.min(100, numberValue(drive.engagedPercent)))}%"></span></div>
|
||||
<strong>${formatPercent(drive.engagedPercent)} engaged</strong>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<section class="dashboard-card dashboard-recent">
|
||||
<h2>Recent drives</h2>
|
||||
${rows}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function favoriteChart(models) {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return {
|
||||
style: "background: conic-gradient(var(--dashboard-track) 0 100%)",
|
||||
rows: `<div class="dashboard-empty">No model usage recorded yet.</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
const topModels = models.slice(0, TOP_MODEL_LIMIT);
|
||||
const total = topModels.reduce((sum, model) => sum + Math.max(1, numberValue(model.weight)), 0);
|
||||
let start = 0;
|
||||
const segments = topModels.map((model, index) => {
|
||||
const end = start + (Math.max(1, numberValue(model.weight)) / total) * 100;
|
||||
const segment = `${FAVORITE_COLORS[index]} ${start}% ${end}%`;
|
||||
start = end;
|
||||
return segment;
|
||||
});
|
||||
|
||||
const rows = topModels.map((model, index) => `
|
||||
<div class="dashboard-model-row">
|
||||
<span class="dashboard-swatch" style="background:${FAVORITE_COLORS[index]}"></span>
|
||||
<div>
|
||||
<strong>${escapeHtml(model.name)}</strong>
|
||||
<small>${formatInt(model.drives)} ${numberValue(model.drives) === 1 ? "drive" : "drives"} using this model</small>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
return {
|
||||
style: `background: conic-gradient(${segments.join(", ")})`,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFavoriteModels(models) {
|
||||
const chart = favoriteChart(models);
|
||||
return `
|
||||
<section class="dashboard-card dashboard-models">
|
||||
<h2>Most used models</h2>
|
||||
<div class="dashboard-model-layout">
|
||||
<div class="dashboard-favorite-donut" style="${chart.style}"></div>
|
||||
<div class="dashboard-model-list">${chart.rows}</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStorage(storage) {
|
||||
const usedPercent = Math.max(0, Math.min(100, numberValue(storage.usedPercent)));
|
||||
const counts = storage.segmentCounts || {};
|
||||
const summary = storage.legacyText || `${formatBytes(storage.usedBytes)} used of ${formatBytes(storage.totalBytes)}`;
|
||||
return `
|
||||
<section class="dashboard-card dashboard-device-card">
|
||||
<h2>Storage</h2>
|
||||
<p class="dashboard-muted">${escapeHtml(summary)}</p>
|
||||
<div class="dashboard-storage-track"><span style="width:${usedPercent}%"></span></div>
|
||||
<div class="dashboard-key-values">
|
||||
<div><span>Dashcam footage</span><strong>${formatInt(counts.standard)} segments</strong></div>
|
||||
<div><span>High-resolution footage</span><strong>${formatInt(counts.highResolution)} segments</strong></div>
|
||||
<div><span>Konik footage</span><strong>${formatInt(counts.alternate)} segments</strong></div>
|
||||
<div><span>Free space</span><strong>${formatBytes(storage.freeBytes)}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderVitals(device) {
|
||||
const uptime = device.uptimeSeconds == null ? "unknown" : formatDuration(device.uptimeSeconds);
|
||||
const cpu = device.cpuTempC == null ? "unknown" : `${formatInt(device.cpuTempC)} C`;
|
||||
return `
|
||||
<section class="dashboard-card dashboard-device-card">
|
||||
<h2>Vitals</h2>
|
||||
<div class="dashboard-key-values">
|
||||
<div><span>Status</span><strong>${escapeHtml(device.status || "Parked")}</strong></div>
|
||||
<div><span>Uptime</span><strong>${escapeHtml(uptime)}</strong></div>
|
||||
<div><span>CPU temp</span><strong>${escapeHtml(cpu)}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSoftware(info = {}) {
|
||||
const changelogUrl = safeUrl(info.changelogUrl);
|
||||
const commitUrl = safeUrl(info.commitUrl);
|
||||
const commitHref = changelogUrl || commitUrl;
|
||||
const fields = [
|
||||
{ label: "Branch", value: info.branchName },
|
||||
{ label: "Build", value: info.buildEnvironment },
|
||||
{ label: "Commit", value: info.commitHash, href: commitHref },
|
||||
{ label: "Version date", value: info.versionDate },
|
||||
{ label: "Fork maintainer", value: info.forkMaintainer },
|
||||
{ label: "Update available", value: info.updateAvailable },
|
||||
];
|
||||
|
||||
return `
|
||||
<section class="dashboard-card dashboard-device-card">
|
||||
<h2>Software</h2>
|
||||
<div class="dashboard-software-list">
|
||||
${fields.map((field) => `
|
||||
<div>
|
||||
<span>${escapeHtml(field.label)}</span>
|
||||
<strong>${field.href ? `<a href="${escapeHtml(field.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(field.value ?? "unknown")}</a>` : escapeHtml(field.value ?? "unknown")}</strong>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindDashboardActions() {
|
||||
const refreshButton = document.getElementById("dashboard_refresh");
|
||||
if (refreshButton) {
|
||||
refreshButton.onclick = () => initializeHome(true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard(state) {
|
||||
const shell = document.getElementById("home_shell");
|
||||
if (!shell) return;
|
||||
|
||||
if (state.status === "error") {
|
||||
shell.innerHTML = `<p class="error">Failed to load data: ${state.error}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status !== "ready" || !data) {
|
||||
shell.innerHTML = "<p>Loading...</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const driveStats = data.driveStats || {};
|
||||
const softwareInfo = data.softwareInfo || {};
|
||||
const diskUsage = Array.isArray(data.diskUsage) ? data.diskUsage : [];
|
||||
const diskError = Array.isArray(data.diskError) ? data.diskError : [];
|
||||
|
||||
const statBlock = (title, stats = {}) => `
|
||||
<div class="drivingStat">
|
||||
<h2>${title}</h2>
|
||||
<div><p>${formatInt(stats.drives)}</p><p>drives</p></div>
|
||||
<div><p>${formatInt(stats.distance)}</p><p>${stats.unit || state.unit}</p></div>
|
||||
<div><p>${formatInt(stats.hours)}</p><p>hours</p></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const diskBlock = (disk = {}) => {
|
||||
const usedPct = Number.parseFloat(disk.usedPercentage) || 0;
|
||||
const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)";
|
||||
return `
|
||||
<div class="disk">
|
||||
<p>${disk.used || "0 GB"} used of ${disk.size || "0 GB"}</p>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bar"
|
||||
style="
|
||||
border-bottom-right-radius: ${rightRadius};
|
||||
border-top-right-radius: ${rightRadius};
|
||||
width: ${Math.max(0, 100 - usedPct)}%;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
shell.innerHTML = `
|
||||
<div class="dashboard dashboard-narrow">
|
||||
<div class="dashboard-error">Failed to load dashboard: ${escapeHtml(state.error)}</div>
|
||||
<button id="dashboard_refresh" class="dashboard-refresh"><i class="bi bi-arrow-clockwise"></i>Refresh</button>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
bindDashboardActions();
|
||||
return;
|
||||
}
|
||||
|
||||
const softwareFields = [
|
||||
["Branch Name", softwareInfo.branchName],
|
||||
["Build", softwareInfo.buildEnvironment],
|
||||
["Commit Hash", softwareInfo.commitHash],
|
||||
["Fork Maintainer", softwareInfo.forkMaintainer],
|
||||
["Update Available", softwareInfo.updateAvailable],
|
||||
["Version Date", softwareInfo.versionDate],
|
||||
];
|
||||
if (state.status !== "ready" || !state.data) {
|
||||
shell.innerHTML = `
|
||||
<div class="dashboard dashboard-narrow">
|
||||
<div class="dashboard-loading">Loading dashboard...</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const softwareMarkup = softwareFields
|
||||
.map(([label, value]) => `<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`)
|
||||
.join("");
|
||||
|
||||
const diskMarkup = diskError.length
|
||||
? `<p>${diskError.join("<br>")}</p>`
|
||||
: (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({}));
|
||||
const data = state.data || {};
|
||||
const dashboard = data.dashboard || fallbackDashboard(data, state.unit);
|
||||
const driveStats = data.driveStats || {};
|
||||
const device = dashboard.device || {};
|
||||
const status = device.status || "Parked";
|
||||
const onlineText = device.online === false ? "device offline" : "device online";
|
||||
|
||||
shell.innerHTML = `
|
||||
<div>
|
||||
<h1>Galaxy</h1>
|
||||
<main class="dashboard">
|
||||
<header class="dashboard-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p><span class="dashboard-status-dot"></span><strong>${escapeHtml(status)}</strong> - ${escapeHtml(onlineText)}</p>
|
||||
</div>
|
||||
<button id="dashboard_refresh" class="dashboard-refresh"><i class="bi bi-arrow-clockwise"></i>Refresh</button>
|
||||
</header>
|
||||
|
||||
<div class="drivingStats">
|
||||
${statBlock("All Time", driveStats.all)}
|
||||
${statBlock("Past Week", driveStats.week)}
|
||||
${statBlock("StarPilot", driveStats.starpilot)}
|
||||
${renderLastDrive(dashboard.lastDrive || fallbackDashboard(data, state.unit).lastDrive)}
|
||||
|
||||
<div class="dashboard-section-label"><span></span>Your driving</div>
|
||||
<div class="dashboard-summary-grid">
|
||||
${statBlock("All time", driveStats.all, state.unit)}
|
||||
${statBlock("Past week", driveStats.week, state.unit)}
|
||||
${statBlock("StarPilot", driveStats.starpilot, state.unit)}
|
||||
</div>
|
||||
|
||||
<h2>Disk Usage</h2>
|
||||
<div class="diskUsage">
|
||||
${diskMarkup}
|
||||
<div class="dashboard-two-column">
|
||||
${renderWeekChart(dashboard.week || {})}
|
||||
${renderRecords(dashboard.records || {})}
|
||||
</div>
|
||||
|
||||
<h2>Software Info</h2>
|
||||
<div class="softwareInfo">
|
||||
<div class="softwareGrid">${softwareMarkup}</div>
|
||||
${renderRecentDrives(dashboard.recentDrives || [])}
|
||||
|
||||
<div class="dashboard-two-column dashboard-model-storage">
|
||||
${renderFavoriteModels(dashboard.favoriteModels || [])}
|
||||
${renderStorage(dashboard.storage || {})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section-label"><span></span>Your device</div>
|
||||
<div class="dashboard-device-grid">
|
||||
${renderVitals(device)}
|
||||
${renderSoftware(data.softwareInfo || {})}
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
bindDashboardActions();
|
||||
}
|
||||
|
||||
async function initializeHome() {
|
||||
async function initializeHome(force = false) {
|
||||
if (force) {
|
||||
HOME_STATE.status = "loading";
|
||||
renderDashboard(HOME_STATE);
|
||||
}
|
||||
|
||||
try {
|
||||
const [statsResponse, unitResponse] = await Promise.all([
|
||||
withTimeout(fetch("/api/stats"), 5000, "stats request"),
|
||||
@@ -136,17 +481,17 @@ async function initializeHome() {
|
||||
HOME_STATE.error = err?.message || String(err);
|
||||
}
|
||||
|
||||
renderDiskUsageSection(HOME_STATE);
|
||||
renderDashboard(HOME_STATE);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
setTimeout(() => {
|
||||
renderDiskUsageSection(HOME_STATE);
|
||||
renderDashboard(HOME_STATE);
|
||||
if (!HOME_STATE.initialized) {
|
||||
HOME_STATE.initialized = true;
|
||||
initializeHome();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return html`<div id="home_shell"><p>Loading...</p></div>`;
|
||||
return html`<div id="home_shell"><p>Loading dashboard...</p></div>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
|
||||
MODULE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(MODULE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MODULE_DIR))
|
||||
|
||||
loggerd_config = ModuleType("openpilot.system.loggerd.config")
|
||||
loggerd_config.get_available_bytes = lambda default=0: default
|
||||
loggerd_config.get_used_bytes = lambda default=0: default
|
||||
sys.modules.setdefault("openpilot.system.loggerd.config", loggerd_config)
|
||||
|
||||
loggerd_deleter = ModuleType("openpilot.system.loggerd.deleter")
|
||||
loggerd_deleter.PRESERVE_ATTR_NAME = "preserve"
|
||||
loggerd_deleter.PRESERVE_ATTR_VALUE = b"1"
|
||||
loggerd_deleter.PRESERVE_COUNT = 0
|
||||
sys.modules.setdefault("openpilot.system.loggerd.deleter", loggerd_deleter)
|
||||
|
||||
loggerd_uploader = ModuleType("openpilot.system.loggerd.uploader")
|
||||
loggerd_uploader.listdir_by_creation = lambda path: [
|
||||
entry.name for entry in sorted(Path(path).iterdir(), key=lambda entry: entry.stat().st_ctime)
|
||||
]
|
||||
sys.modules.setdefault("openpilot.system.loggerd.uploader", loggerd_uploader)
|
||||
|
||||
model_manager = ModuleType("openpilot.starpilot.assets.model_manager")
|
||||
model_manager.canonical_model_key = lambda value: str(value or "").strip().lower().replace(" ", "-")
|
||||
sys.modules.setdefault("openpilot.starpilot.assets.model_manager", model_manager)
|
||||
|
||||
starpilot_variables = ModuleType("openpilot.starpilot.common.starpilot_variables")
|
||||
starpilot_variables.THEME_SAVE_PATH = Path("/tmp/dashboard-test-themes")
|
||||
starpilot_variables.VIDEO_CACHE_PATH = Path("/tmp/dashboard-test-video-cache")
|
||||
sys.modules.setdefault("openpilot.starpilot.common.starpilot_variables", starpilot_variables)
|
||||
|
||||
theme_manager = ModuleType("openpilot.starpilot.assets.theme_manager")
|
||||
theme_manager.HOLIDAY_THEME_PATH = Path("/tmp/dashboard-test-holiday-themes")
|
||||
sys.modules.setdefault("openpilot.starpilot.assets.theme_manager", theme_manager)
|
||||
|
||||
import utilities
|
||||
|
||||
|
||||
def _simple_module(name, **attrs):
|
||||
module = ModuleType(name)
|
||||
for attr, value in attrs.items():
|
||||
setattr(module, attr, value)
|
||||
return module
|
||||
|
||||
|
||||
def _install_server_import_stubs():
|
||||
cereal_module = _simple_module("cereal")
|
||||
car_module = _simple_module("cereal.car", CarParams=SimpleNamespace(from_bytes=lambda _: SimpleNamespace(__enter__=lambda self: self, __exit__=lambda *args: None)))
|
||||
custom_module = _simple_module("cereal.custom", StarPilotCarParams=object)
|
||||
log_module = _simple_module(
|
||||
"cereal.log",
|
||||
Event=object,
|
||||
DeviceState=SimpleNamespace(NetworkType=SimpleNamespace(none=0)),
|
||||
LiveDelayData=SimpleNamespace(Status=SimpleNamespace(estimated=1, unestimated=2, invalid=3)),
|
||||
)
|
||||
messaging_module = _simple_module(
|
||||
"cereal.messaging",
|
||||
SubMaster=lambda *args, **kwargs: {},
|
||||
PubMaster=lambda *args, **kwargs: SimpleNamespace(send=lambda *send_args, **send_kwargs: None),
|
||||
new_message=lambda *args, **kwargs: SimpleNamespace(),
|
||||
log_from_bytes=lambda *args, **kwargs: SimpleNamespace(liveDelay=SimpleNamespace(status=0)),
|
||||
sub_sock=lambda *args, **kwargs: None,
|
||||
)
|
||||
cereal_module.car = car_module
|
||||
cereal_module.custom = custom_module
|
||||
cereal_module.log = log_module
|
||||
cereal_module.messaging = messaging_module
|
||||
|
||||
sys.modules["cereal"] = cereal_module
|
||||
sys.modules["cereal.car"] = car_module
|
||||
sys.modules["cereal.custom"] = custom_module
|
||||
sys.modules["cereal.log"] = log_module
|
||||
sys.modules["cereal.messaging"] = messaging_module
|
||||
|
||||
sys.modules["opendbc.can.parser"] = _simple_module("opendbc.can.parser", CANParser=lambda *args, **kwargs: SimpleNamespace())
|
||||
sys.modules["opendbc.car.gm.values"] = _simple_module("opendbc.car.gm.values", GMFlags=SimpleNamespace(SASCM=SimpleNamespace(value=1)))
|
||||
sys.modules["opendbc.car.toyota.carcontroller"] = _simple_module("opendbc.car.toyota.carcontroller", LOCK_CMD=b"", UNLOCK_CMD=b"")
|
||||
sys.modules["opendbc.car.toyota.values"] = _simple_module(
|
||||
"opendbc.car.toyota.values",
|
||||
ToyotaStarPilotFlags=SimpleNamespace(SMART_DSU=SimpleNamespace(value=1), ZSS=SimpleNamespace(value=2)),
|
||||
)
|
||||
|
||||
sys.modules["openpilot.common.realtime"] = _simple_module("openpilot.common.realtime", DT_HW=0.01)
|
||||
sys.modules["openpilot.common.time_helpers"] = _simple_module("openpilot.common.time_helpers", system_time_valid=lambda: True)
|
||||
sys.modules["openpilot.system.hardware"] = _simple_module(
|
||||
"openpilot.system.hardware",
|
||||
HARDWARE=SimpleNamespace(reboot=lambda: None, get_device_type=lambda: "pc", get_network_type=lambda: 1),
|
||||
PC=True,
|
||||
)
|
||||
sys.modules["openpilot.system.hardware.hw"] = _simple_module(
|
||||
"openpilot.system.hardware.hw",
|
||||
Paths=SimpleNamespace(log_root=lambda **kwargs: "/tmp/dashboard-test-routes/"),
|
||||
)
|
||||
sys.modules["openpilot.system.version"] = _simple_module("openpilot.system.version", get_build_metadata=lambda: SimpleNamespace())
|
||||
sys.modules["openpilot.tools.longitudinal_maneuvers.capabilities"] = _simple_module(
|
||||
"openpilot.tools.longitudinal_maneuvers.capabilities",
|
||||
get_longitudinal_maneuver_support=lambda *args, **kwargs: {},
|
||||
)
|
||||
sys.modules["panda"] = _simple_module("panda", Panda=lambda *args, **kwargs: SimpleNamespace(can_send=lambda *send_args, **send_kwargs: None))
|
||||
|
||||
model_manager.is_builtin_model_key = lambda value: False
|
||||
model_manager.model_key_aliases = lambda value: [value]
|
||||
theme_manager.THEME_COMPONENT_PARAMS = {}
|
||||
|
||||
sys.modules["openpilot.starpilot.common.accel_profile"] = _simple_module(
|
||||
"openpilot.starpilot.common.accel_profile",
|
||||
CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY="CustomAccelProfileInitialized",
|
||||
CUSTOM_ACCEL_PROFILE_PARAM_KEYS=[],
|
||||
build_custom_accel_profile_defaults=lambda *args, **kwargs: {},
|
||||
custom_accel_profile_is_initialized=lambda *args, **kwargs: False,
|
||||
normalize_acceleration_profile=lambda value: value,
|
||||
)
|
||||
sys.modules["openpilot.starpilot.common.maps_catalog"] = _simple_module(
|
||||
"openpilot.starpilot.common.maps_catalog",
|
||||
MAPS_CATALOG=[],
|
||||
MAP_SCHEDULE_OPTIONS=[],
|
||||
get_selected_map_entries=lambda *args, **kwargs: [],
|
||||
sanitize_selected_locations_csv=lambda value: value,
|
||||
schedule_label=lambda value: value,
|
||||
schedule_param_value=lambda value: value,
|
||||
)
|
||||
sys.modules["openpilot.starpilot.common.model_versions"] = _simple_module(
|
||||
"openpilot.starpilot.common.model_versions",
|
||||
is_tinygrad_model_version=lambda *args, **kwargs: False,
|
||||
uses_combined_driving_artifacts=lambda *args, **kwargs: False,
|
||||
uses_split_off_policy_artifacts=lambda *args, **kwargs: False,
|
||||
)
|
||||
sys.modules["openpilot.starpilot.common.experimental_state"] = _simple_module(
|
||||
"openpilot.starpilot.common.experimental_state",
|
||||
sync_persist_chill_state=lambda *args, **kwargs: None,
|
||||
sync_persist_experimental_state=lambda *args, **kwargs: None,
|
||||
)
|
||||
sys.modules["openpilot.starpilot.common.favorite_slots"] = _simple_module(
|
||||
"openpilot.starpilot.common.favorite_slots",
|
||||
FAVORITE_SLOTS_PARAM="FavoriteSlots",
|
||||
normalize_favorite_slots=lambda *args, **kwargs: "",
|
||||
)
|
||||
sys.modules["openpilot.starpilot.common.starpilot_utilities"] = _simple_module(
|
||||
"openpilot.starpilot.common.starpilot_utilities",
|
||||
delete_file=lambda *args, **kwargs: None,
|
||||
get_lock_status=lambda *args, **kwargs: {},
|
||||
run_cmd=lambda *args, **kwargs: None,
|
||||
)
|
||||
for name, value in {
|
||||
"ACTIVE_THEME_PATH": Path("/tmp/dashboard-test-active-theme"),
|
||||
"ERROR_LOGS_PATH": "/tmp/dashboard-test-errors",
|
||||
"EXCLUDED_KEYS": set(),
|
||||
"LEGACY_STARPILOT_PARAM_RENAMES": {},
|
||||
"MAPS_PATH": Path("/tmp/dashboard-test-maps"),
|
||||
"MODELS_PATH": Path("/tmp/dashboard-test-models"),
|
||||
"RESOURCES_REPO": "resources",
|
||||
"SCREEN_RECORDINGS_PATH": Path("/tmp/dashboard-test-recordings"),
|
||||
"STOCK_THEME_PATH": Path("/tmp/dashboard-test-stock-theme"),
|
||||
"THEME_SAVE_PATH": Path("/tmp/dashboard-test-themes"),
|
||||
}.items():
|
||||
setattr(starpilot_variables, name, value)
|
||||
starpilot_variables.default_ev_tuning_enabled = lambda *args, **kwargs: False
|
||||
starpilot_variables.migrate_cancel_button_controls = lambda *args, **kwargs: False
|
||||
starpilot_variables.update_starpilot_toggles = lambda *args, **kwargs: None
|
||||
|
||||
sys.modules["openpilot.starpilot.common.testing_grounds"] = _simple_module(
|
||||
"openpilot.starpilot.common.testing_grounds",
|
||||
DEFAULT_TESTING_GROUND_VARIANT="standard",
|
||||
TESTING_GROUND_VARIANT_LABELS={},
|
||||
TESTING_GROUND_VARIANTS=("standard",),
|
||||
TESTING_GROUNDS_SCHEMA_VERSION=1,
|
||||
TESTING_GROUNDS_SLOT_DEFINITIONS=(),
|
||||
TESTING_GROUNDS_STATE_PATH=Path("/tmp/dashboard-test-testing-grounds.json"),
|
||||
)
|
||||
sys.modules["openpilot.starpilot.navigation.destination_store"] = _simple_module(
|
||||
"openpilot.starpilot.navigation.destination_store",
|
||||
normalize_destination_payload=lambda payload: payload,
|
||||
update_recent_destinations=lambda *args, **kwargs: [],
|
||||
)
|
||||
sys.modules["openpilot.starpilot.system." + "the_" + "po" + "nd.factory_reset"] = _simple_module(
|
||||
"openpilot.starpilot.system." + "the_" + "po" + "nd.factory_reset",
|
||||
remove_path=lambda *args, **kwargs: None,
|
||||
)
|
||||
sys.modules["openpilot.starpilot.system." + "the_" + "po" + "nd.utilities"] = utilities
|
||||
|
||||
|
||||
class FakeParams:
|
||||
def __init__(self, values=None):
|
||||
self.values = values or {}
|
||||
|
||||
def get(self, key, encoding=None, default=None, block=False):
|
||||
del block
|
||||
value = self.values.get(key, default)
|
||||
if encoding and isinstance(value, bytes):
|
||||
return value.decode(encoding, errors="replace")
|
||||
return value
|
||||
|
||||
def get_bool(self, key):
|
||||
value = self.values.get(key, False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def put(self, key, value):
|
||||
self.values[key] = value
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
def __init__(self, kind, log_mono_time, payload):
|
||||
self._kind = kind
|
||||
self.logMonoTime = log_mono_time
|
||||
setattr(self, kind, payload)
|
||||
|
||||
def which(self):
|
||||
return self._kind
|
||||
|
||||
|
||||
def msg(kind, seconds, payload):
|
||||
return FakeMessage(kind, int(seconds * 1e9), payload)
|
||||
|
||||
|
||||
def events(*names):
|
||||
return [SimpleNamespace(name=name) for name in names]
|
||||
|
||||
|
||||
def test_route_metrics_count_selected_attention_events():
|
||||
route = {
|
||||
"name": "route-a",
|
||||
"segmentCount": 1,
|
||||
"startedAt": None,
|
||||
}
|
||||
messages = [
|
||||
msg("initData", 0, SimpleNamespace(params={"DrivingModelName": b"North Star"})),
|
||||
msg("carState", 0, SimpleNamespace(vEgo=10.0)),
|
||||
msg("selfdriveState", 0, SimpleNamespace(enabled=True)),
|
||||
msg("onroadEvents", 1, events("promptDriverDistracted")),
|
||||
msg("onroadEvents", 2, events("promptDriverDistracted")),
|
||||
msg("onroadEvents", 3, events()),
|
||||
msg("onroadEvents", 4, events("promptDriverDistracted")),
|
||||
msg("onroadEvents", 5, events("driverUnresponsive")),
|
||||
msg("onroadEvents", 6, events("driverUnresponsive")),
|
||||
msg("carState", 10, SimpleNamespace(vEgo=10.0)),
|
||||
msg("selfdriveState", 10, SimpleNamespace(enabled=False)),
|
||||
]
|
||||
|
||||
drive = utilities._analyze_route_messages(messages, route, {}, is_metric=False)
|
||||
|
||||
assert drive["distance"] == 0.1
|
||||
assert drive["duration"] == 10
|
||||
assert drive["avgSpeed"] == 22
|
||||
assert drive["engagedPercent"] == 100
|
||||
assert drive["model"] == "North Star"
|
||||
assert drive["distractedMoments"] == 2
|
||||
assert drive["unresponsiveMoments"] == 1
|
||||
|
||||
|
||||
def test_route_model_falls_back_to_catalog_key():
|
||||
route = {"name": "route-a", "segmentCount": 1, "startedAt": None}
|
||||
messages = [
|
||||
msg("initData", 0, SimpleNamespace(params={"Model": b"orion"})),
|
||||
]
|
||||
model_names = {"orion": {"name": "Orion", "series": "City", "key": "orion"}}
|
||||
|
||||
drive = utilities._analyze_route_messages(messages, route, model_names, is_metric=True)
|
||||
|
||||
assert drive["model"] == "Orion"
|
||||
|
||||
|
||||
def test_route_model_reads_logged_param_entries():
|
||||
route = {"name": "route-a", "segmentCount": 1, "startedAt": None}
|
||||
params = SimpleNamespace(entries=[
|
||||
SimpleNamespace(key="Model", value=b"orion"),
|
||||
])
|
||||
messages = [
|
||||
msg("initData", 0, SimpleNamespace(params=params)),
|
||||
]
|
||||
model_names = {"orion": {"name": "Orion", "series": "City", "key": "orion"}}
|
||||
|
||||
drive = utilities._analyze_route_messages(messages, route, model_names, is_metric=True)
|
||||
|
||||
assert drive["model"] == "Orion"
|
||||
|
||||
|
||||
def test_week_and_record_summaries_use_analyzed_drives():
|
||||
now = utilities.datetime(2026, 6, 16, 12, 0, 0)
|
||||
drives = [
|
||||
{"date": "2026-06-15T08:00:00", "distance": 10.0, "duration": 1200, "engagedSeconds": 900, "distractedMoments": 0, "unresponsiveMoments": 0},
|
||||
{"date": "2026-06-16T08:00:00", "distance": 4.0, "duration": 600, "engagedSeconds": 600, "distractedMoments": 1, "unresponsiveMoments": 0},
|
||||
{"date": "2026-06-08T08:00:00", "distance": 25.0, "duration": 1800, "engagedSeconds": 900, "distractedMoments": 0, "unresponsiveMoments": 0},
|
||||
]
|
||||
|
||||
week = utilities._build_week_summary(drives, now, is_metric=False)
|
||||
records = utilities._build_records(drives, is_metric=False)
|
||||
|
||||
assert week["distance"] == 14.0
|
||||
assert week["drives"] == 2
|
||||
assert week["engagedPercent"] == 83
|
||||
assert records["longestDrive"]["value"] == "25.0"
|
||||
assert records["highestStreak"]["value"] == "2 days"
|
||||
assert records["longestUndistractedDrive"]["value"] == "0.5 hours"
|
||||
assert records["cleanDriveStreak"]["value"] == "3 drives"
|
||||
|
||||
|
||||
def test_attention_records_use_distracted_for_undistracted_and_unresponsive_for_clean_streak():
|
||||
drives = [
|
||||
{"date": "2026-06-15T08:00:00", "distance": 10.0, "duration": 1200, "engagedSeconds": 900, "distractedMoments": 0, "unresponsiveMoments": 0},
|
||||
{"date": "2026-06-16T08:00:00", "distance": 4.0, "duration": 3600, "engagedSeconds": 600, "distractedMoments": 1, "unresponsiveMoments": 0},
|
||||
{"date": "2026-06-17T08:00:00", "distance": 6.0, "duration": 600, "engagedSeconds": 400, "distractedMoments": 0, "unresponsiveMoments": 1},
|
||||
{"date": "2026-06-18T08:00:00", "distance": 8.0, "duration": 900, "engagedSeconds": 800, "distractedMoments": 0, "unresponsiveMoments": 0},
|
||||
]
|
||||
|
||||
records = utilities._build_records(drives, is_metric=False)
|
||||
|
||||
assert records["longestUndistractedDrive"]["value"] == "0.3 hours"
|
||||
assert records["cleanDriveStreak"]["value"] == "2 drives"
|
||||
|
||||
|
||||
def test_route_sampling_bounds_analyzed_segments():
|
||||
route = {
|
||||
"name": "route-a",
|
||||
"segmentCount": 10,
|
||||
"segments": [{"num": idx, "path": Path(f"/tmp/segment-{idx}")} for idx in range(10)],
|
||||
}
|
||||
|
||||
sampled = utilities._sample_route_info(route, limit=3)
|
||||
|
||||
assert sampled["segmentCount"] == 10
|
||||
assert sampled["analysisSegmentCount"] == 3
|
||||
assert [segment["num"] for segment in sampled["segments"]] == [0, 4, 9]
|
||||
|
||||
|
||||
def test_top_models_are_ranked_from_persisted_usage_not_favorites():
|
||||
params = FakeParams({
|
||||
"AvailableModels": "orion,vega,atlas,nova",
|
||||
"AvailableModelNames": "Orion,Vega,Atlas,Nova",
|
||||
"AvailableModelSeries": "City,Highway,Experimental,Custom",
|
||||
"UserFavorites": "orion",
|
||||
"CommunityFavorites": "vega",
|
||||
})
|
||||
persistent = {
|
||||
"modelUsage": {
|
||||
"orion": {"name": "Orion", "drives": 3},
|
||||
"vega": {"name": "Vega", "drives": 1},
|
||||
"atlas": {"name": "Atlas", "drives": 9},
|
||||
"nova": {"name": "Nova", "drives": 4},
|
||||
},
|
||||
}
|
||||
|
||||
favorites = utilities._build_favorite_models(params, persistent)
|
||||
|
||||
assert [model["name"] for model in favorites] == ["Atlas", "Nova", "Orion"]
|
||||
assert len(favorites) == 3
|
||||
assert favorites[0]["userFavorite"] is False
|
||||
assert favorites[0]["drives"] == 9
|
||||
assert favorites[2]["userFavorite"] is True
|
||||
|
||||
|
||||
def test_persistent_personal_records_and_model_usage_survive_empty_routes():
|
||||
params = FakeParams({"AvailableModels": "orion", "AvailableModelNames": "Orion", "CommunityFavorites": "orion"})
|
||||
drives = [
|
||||
{
|
||||
"name": "route-1",
|
||||
"date": "2026-06-15T08:00:00",
|
||||
"distanceMeters": 10000.0,
|
||||
"duration": 1200,
|
||||
"engagedSeconds": 900.0,
|
||||
"model": "Orion",
|
||||
"distractedMoments": 0,
|
||||
"unresponsiveMoments": 0,
|
||||
"routeModifiedAt": 100,
|
||||
},
|
||||
{
|
||||
"name": "route-2",
|
||||
"date": "2026-06-16T08:00:00",
|
||||
"distanceMeters": 5000.0,
|
||||
"duration": 600,
|
||||
"engagedSeconds": 600.0,
|
||||
"model": "Orion",
|
||||
"distractedMoments": 1,
|
||||
"unresponsiveMoments": 0,
|
||||
"routeModifiedAt": 100,
|
||||
},
|
||||
{
|
||||
"name": "route-3",
|
||||
"date": "2026-06-17T08:00:00",
|
||||
"distanceMeters": 20000.0,
|
||||
"duration": 1800,
|
||||
"engagedSeconds": 900.0,
|
||||
"model": "Orion",
|
||||
"distractedMoments": 0,
|
||||
"unresponsiveMoments": 0,
|
||||
"routeModifiedAt": 100,
|
||||
},
|
||||
]
|
||||
|
||||
persistent = utilities._update_dashboard_persistent_stats(params, drives, wall_now=1000)
|
||||
records = utilities._display_personal_records(persistent, is_metric=False)
|
||||
favorites = utilities._build_favorite_models(params, persistent)
|
||||
|
||||
assert records["longestDrive"]["value"] == "12.4"
|
||||
assert records["mostEngagedDay"]["value"] == "100%"
|
||||
assert records["bestWeek"]["value"] == "21.7"
|
||||
assert records["highestStreak"]["value"] == "3 days"
|
||||
assert records["longestUndistractedDrive"]["value"] == "0.5 hours"
|
||||
assert records["cleanDriveStreak"]["value"] == "3 drives"
|
||||
assert favorites[0]["drives"] == 3
|
||||
|
||||
restored = utilities._update_dashboard_persistent_stats(params, [], wall_now=2000)
|
||||
restored_records = utilities._display_personal_records(restored, is_metric=False)
|
||||
|
||||
assert restored_records["longestDrive"]["value"] == "12.4"
|
||||
assert restored_records["mostEngagedDay"]["value"] == "100%"
|
||||
assert restored_records["bestWeek"]["value"] == "21.7"
|
||||
assert restored_records["highestStreak"]["value"] == "3 days"
|
||||
assert restored_records["longestUndistractedDrive"]["value"] == "0.5 hours"
|
||||
assert restored_records["cleanDriveStreak"]["value"] == "3 drives"
|
||||
|
||||
|
||||
def test_github_urls_accept_owner_repo_origin():
|
||||
assert utilities.get_github_changelog_url("owner/repo", "main") == "https://github.com/owner/repo/commits/main/"
|
||||
assert utilities.get_github_changelog_url("github.com/owner/repo", "main") == "https://github.com/owner/repo/commits/main/"
|
||||
assert utilities.get_github_commit_url("owner/repo", "abcdef") == "https://github.com/owner/repo/commit/abcdef"
|
||||
|
||||
|
||||
def _load_server_module():
|
||||
_install_server_import_stubs()
|
||||
spec = importlib.util.spec_from_file_location("dashboard_server", MODULE_DIR / ("the_" + "po" + "nd.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_stats_endpoint_keeps_existing_keys_and_adds_dashboard(monkeypatch):
|
||||
server = _load_server_module()
|
||||
assert getattr(server, "_import_" + "po" + "nd_web_symbols")()
|
||||
|
||||
app = server.Flask(
|
||||
"dashboard_test",
|
||||
template_folder=str(MODULE_DIR / "templates"),
|
||||
static_folder=str(MODULE_DIR / "assets"),
|
||||
)
|
||||
server.setup(app)
|
||||
|
||||
fake_metadata = SimpleNamespace(
|
||||
channel="test-branch",
|
||||
release_channel=False,
|
||||
tested_channel=False,
|
||||
openpilot=SimpleNamespace(
|
||||
git_commit="abcdef123456",
|
||||
git_normalized_origin="owner/repo",
|
||||
git_commit_date="'1700000000 2023-11-14 00:00:00 -0000'",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(server, "get_build_metadata", lambda: fake_metadata)
|
||||
monkeypatch.setattr(server, "FOOTAGE_PATHS", [])
|
||||
monkeypatch.setattr(server, "params", FakeParams({"UpdaterFetchAvailable": False, "IsMetric": False}))
|
||||
monkeypatch.setattr(server.utilities, "get_disk_usage", lambda: [{"free": "1 GB", "size": "2 GB", "used": "1 GB", "usedPercentage": "50.00%"}])
|
||||
monkeypatch.setattr(server.utilities, "get_drive_stats", lambda: {"all": {"drives": 0, "distance": 0, "hours": 0, "unit": "miles"}})
|
||||
monkeypatch.setattr(server.utilities, "get_dashboard_stats", lambda footage_paths, params_obj: {"lastDrive": {}, "recentDrives": []})
|
||||
|
||||
response = app.test_client().get("/api/stats")
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "diskUsage" in payload
|
||||
assert "driveStats" in payload
|
||||
assert "softwareInfo" in payload
|
||||
assert payload["dashboard"]["recentDrives"] == []
|
||||
@@ -5175,17 +5175,27 @@ def setup(app):
|
||||
else:
|
||||
env = short_branch
|
||||
|
||||
software_info = {
|
||||
"branchName": build_metadata.channel,
|
||||
"buildEnvironment": env,
|
||||
"changelogUrl": utilities.get_github_changelog_url(build_metadata.openpilot.git_normalized_origin, build_metadata.channel),
|
||||
"commitHash": build_metadata.openpilot.git_commit,
|
||||
"commitUrl": utilities.get_github_commit_url(build_metadata.openpilot.git_normalized_origin, build_metadata.openpilot.git_commit),
|
||||
"forkMaintainer": utilities.get_repo_owner(build_metadata.openpilot.git_normalized_origin),
|
||||
"updateAvailable": "Yes" if params.get_bool("UpdaterFetchAvailable") else "No",
|
||||
"versionDate": utilities.format_git_date(build_metadata.openpilot.git_commit_date),
|
||||
}
|
||||
|
||||
try:
|
||||
dashboard_stats = utilities.get_dashboard_stats(FOOTAGE_PATHS, params)
|
||||
except Exception:
|
||||
dashboard_stats = utilities.get_dashboard_stats([], params)
|
||||
|
||||
return {
|
||||
"diskUsage": utilities.get_disk_usage(),
|
||||
"driveStats": utilities.get_drive_stats(),
|
||||
"softwareInfo": {
|
||||
"branchName": build_metadata.channel,
|
||||
"buildEnvironment": env,
|
||||
"commitHash": build_metadata.openpilot.git_commit,
|
||||
"forkMaintainer": utilities.get_repo_owner(build_metadata.openpilot.git_normalized_origin),
|
||||
"updateAvailable": "Yes" if params.get_bool("UpdaterFetchAvailable") else "No",
|
||||
"versionDate": utilities.format_git_date(build_metadata.openpilot.git_commit_date),
|
||||
},
|
||||
"softwareInfo": software_info,
|
||||
"dashboard": dashboard_stats,
|
||||
}
|
||||
|
||||
@app.route("/api/plots/live", methods=["GET"])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user