mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 15:33:45 +08:00
280 lines
20 KiB
JavaScript
280 lines
20 KiB
JavaScript
import { GxState } from "./state.js"
|
||
import { GxNotice } from "./notice.js"
|
||
import { LocalAccess } from "./local-access.js"
|
||
import { MonitorFeed } from "./monitor-feed.js"
|
||
import { SoftwareStatusFeed } from "./software-status.js"
|
||
import { ModelStatusFeed } from "./models.js"
|
||
import { DriveStatsFeed } from "./drive-stats.js"
|
||
|
||
// The six lightweight motifs follow the original Galaxy Personal Records row.
|
||
const RECORD_ART = {
|
||
longestDrive: ["M4 12h16", "m14 6 6 6-6 6"],
|
||
mostEngagedDay: ["M20 11a8 8 0 1 1-5-7", "m8 11 4 4 8-9"],
|
||
bestWeek: ["M4 4v16h16", "m7 15 5-5 4 2 5-7", "M17 5h4v4"],
|
||
highestStreak: ["m13 2-9 12h7l-1 8 10-13h-7z"],
|
||
longestUndistractedDrive: ["m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6z", "m8 12 3 3 5-6"],
|
||
cleanDriveStreak: ["m12 2 2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z", "M20 2v4M18 4h4"],
|
||
}
|
||
export const recordArt = (id) => RECORD_ART[id] || ["m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z"]
|
||
|
||
const shown = (value) => typeof value === "string" && value.trim() ? value : "Unavailable"
|
||
const measure = (value, unit, digits = 0) =>
|
||
typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(digits)}${unit}` : "Unavailable"
|
||
const counted = (value, noun) => `${value} ${noun}${value === 1 ? "" : "s"}`
|
||
const distance = (meters, isMetric) => measure(meters === null ? null : meters / (isMetric ? 1000 : 1609.344),
|
||
isMetric ? " km" : " mi", 1)
|
||
const hours = (seconds) => measure(seconds === null ? null : seconds / 3600, " h", 1)
|
||
const percent = (value) => measure(value, "%")
|
||
const duration = (seconds) => {
|
||
if (seconds == null) return "Unavailable"
|
||
const minutes = Math.round(seconds / 60)
|
||
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m` : `${minutes}m`
|
||
}
|
||
const driveDate = (start, end = null) => {
|
||
if (start === null) return "Date unavailable"
|
||
const date = new Date(start * 1000)
|
||
if (!Number.isFinite(date.getTime())) return "Date unavailable"
|
||
const calendar = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" })
|
||
const clock = (value) => new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value * 1000))
|
||
const day = calendar.format(date)
|
||
const endDay = end !== null ? calendar.format(new Date(end * 1000)) : null
|
||
return `${day} · ${clock(start)}${end !== null ? `–${endDay === day ? "" : endDay + " · "}${clock(end)}` : ""}`
|
||
}
|
||
const dayLabel = (date) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(`${date}T00:00:00Z`))
|
||
const recordDisplay = (record, isMetric) => record.value === null ? "Unavailable" :
|
||
record.unit === "m" ? distance(record.value, isMetric) : record.unit === "s" ? duration(record.value) :
|
||
record.unit === "days" || record.unit === "drives" ? counted(record.value, record.unit.slice(0, -1)) :
|
||
`${record.value}${record.unit === "%" ? "%" : record.unit ? ` ${record.unit}` : ""}`
|
||
|
||
export function driveSummary(feed) {
|
||
const data = feed.status === "ready" ? feed.data : null
|
||
if (!data) return null
|
||
const isMetric = data.isMetric
|
||
const last = data.lastDrive
|
||
const analysisStatus = (drive) => {
|
||
const failure = data.analysis.failures?.find((item) => item.routeId === drive.routeId)
|
||
return { analysisLabel: failure?.retryStopped ? "Analysis paused" : failure ? "Retry scheduled" : "Analysis pending",
|
||
analysisError: failure?.reason || drive.reason || "" }
|
||
}
|
||
const maximum = Math.max(1, ...data.week.days.map((day) => day.distanceMeters ?? 0))
|
||
const models = data.models.slice(0, 3)
|
||
const modelTotal = models.reduce((sum, model) => sum + (model.distanceMeters ?? 0), 0)
|
||
const colors = ["#5ec8c8", "#8b6cc5", "#d4a060"]
|
||
let offset = 0
|
||
const stops = models.map((model, index) => {
|
||
const next = offset + (modelTotal > 0 ? (model.distanceMeters ?? 0) / modelTotal * 100 : 0)
|
||
const stop = `${colors[index]} ${offset}% ${next}%`
|
||
offset = next
|
||
return stop
|
||
})
|
||
return {
|
||
analysis: data.analysis,
|
||
last: last && { ...analysisStatus(last), routeId: last.routeId, date: driveDate(last.startTime, last.endTime), complete: last.complete,
|
||
distance: distance(last.distanceMeters, isMetric), duration: duration(last.durationSeconds), engaged: percent(last.engagedPercent),
|
||
aol: percent(last.aolPercent), aolDuration: duration(last.aolSeconds),
|
||
average: measure(last.distanceMeters === null || !(last.durationSeconds > 0) ? null : last.distanceMeters / last.durationSeconds * (isMetric ? 3.6 : 2.236936), isMetric ? " km/h" : " mph"),
|
||
model: shown(last.model), distracted: last.distractedMoments === null ? "Unavailable" : String(last.distractedMoments),
|
||
unresponsive: last.unresponsiveMoments === null ? "Unavailable" : String(last.unresponsiveMoments) },
|
||
totals: { distance: distance(data.totals.distanceMeters, isMetric), duration: hours(data.totals.durationSeconds),
|
||
engaged: duration(data.totals.engagedSeconds), drives: data.totals.drives,
|
||
aol: duration(data.totals.aolSeconds), aolDrives: data.totals.aolDrives ?? 0 },
|
||
week: { timezone: data.week.timezone || "UTC", distance: distance(data.week.distanceMeters, isMetric), duration: hours(data.week.durationSeconds),
|
||
engaged: percent(data.week.engagedPercent), progress: data.week.engagedPercent ?? 0, drives: data.week.drives,
|
||
aol: percent(data.week.aolPercent), aolDrives: data.week.aolDrives ?? 0,
|
||
days: data.week.days.map((day) => ({ date: day.date, label: dayLabel(day.date),
|
||
distance: distance(day.distanceMeters, isMetric), height: day.distanceMeters === null ? 0 : day.distanceMeters / maximum * 100 })) },
|
||
recent: data.recentDrives.slice(0, 5).map((drive) => ({ ...analysisStatus(drive), routeId: drive.routeId, date: driveDate(drive.startTime, drive.endTime),
|
||
complete: drive.complete, ignored: drive.ignored, distance: distance(drive.distanceMeters, isMetric),
|
||
duration: duration(drive.durationSeconds), engaged: percent(drive.engagedPercent), aol: percent(drive.aolPercent), model: shown(drive.model) })),
|
||
records: data.records.map((record) => ({ id: record.id, label: record.label, display: recordDisplay(record, isMetric) })),
|
||
models: models.map((model, index) => ({ name: model.name, distance: distance(model.distanceMeters, isMetric),
|
||
drives: model.drives, color: colors[index] })),
|
||
modelStyle: modelTotal > 0 ? `background:conic-gradient(${stops.join(",")})` : "",
|
||
}
|
||
}
|
||
|
||
// Each card retains its own source status. Missing/expired data is never a zero or a healthy state.
|
||
export function homeSummary(monitor, model, software) {
|
||
const live = monitor.status === "current" ? monitor.snapshot : null
|
||
const loaded = model.status === "ready" ? model.data : null
|
||
const installed = software.status === "ready" ? software.data : null
|
||
return {
|
||
system: {
|
||
available: !!live,
|
||
cpu: measure(live?.cpuPercent, "%"),
|
||
memory: measure(live?.memory?.percent, "%"),
|
||
storage: live?.storage?.usedGiB != null && live?.storage?.totalGiB != null
|
||
? `${measure(live.storage.usedGiB, " GiB", 1)} / ${measure(live.storage.totalGiB, " GiB", 1)}` : "Unavailable",
|
||
observed: live ? new Date(live.sampledAt * 1000).toLocaleString() : "Unavailable",
|
||
},
|
||
model: {
|
||
available: !!loaded,
|
||
health: loaded ? shown(loaded.health).replaceAll("-", " ") : "Unavailable",
|
||
variant: loaded?.loadedId ? loaded.variant === "chestnut" ? "Chestnut big" : "Small" : "Identity unavailable",
|
||
fallback: loaded?.fallbackReason === "chestnut-run-stalled" ? "Chestnut output stopped; Small restarted for this drive" :
|
||
loaded?.fallbackReason === "chestnut-load-failed" ? "Small model after Chestnut load failure" : null,
|
||
},
|
||
software: {
|
||
available: !!installed,
|
||
version: shown(installed?.installed?.displayVersion ?? installed?.installed?.version),
|
||
branch: shown(installed?.installed?.branch),
|
||
update: installed?.updater?.finalizedUpdateReady === true ? "Finalized update ready" :
|
||
installed?.updater?.finalizedUpdateReady === false ? "None reported" : "Unavailable",
|
||
},
|
||
}
|
||
}
|
||
|
||
export const Home = {
|
||
name: "Home",
|
||
components: { GxState, GxNotice, LocalAccess },
|
||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||
go: { type: Function, required: true } },
|
||
data: () => ({
|
||
monitor: { snapshot: null, status: "idle", error: "" },
|
||
model: { data: null, status: "idle", error: "" },
|
||
software: { data: null, status: "idle", error: "" },
|
||
drives: { data: null, status: "idle", error: "", busy: false },
|
||
}),
|
||
computed: {
|
||
summary() { return homeSummary(this.monitor, this.model, this.software) },
|
||
driving() { return driveSummary(this.drives) },
|
||
},
|
||
created() {
|
||
const revoked = () => { this.stopFeeds(); this.unauthorized() }
|
||
this.monitorFeed = new MonitorFeed({ mode: this.mode, publish: (update) => Object.assign(this.monitor, update), unauthorized: revoked })
|
||
this.modelFeed = new ModelStatusFeed({ publish: (update) => Object.assign(this.model, update), unauthorized: revoked })
|
||
this.softwareFeed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => Object.assign(this.software, update), unauthorized: revoked })
|
||
this.driveFeed = new DriveStatsFeed({ publish: (update) => Object.assign(this.drives, update), unauthorized: revoked })
|
||
},
|
||
mounted() {
|
||
this.visibility = () => { if (document.hidden) this.stopFeeds(); else this.startFeeds() }
|
||
document.addEventListener("visibilitychange", this.visibility)
|
||
if (!document.hidden) this.startFeeds()
|
||
},
|
||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.stopFeeds() },
|
||
methods: {
|
||
recordArt,
|
||
counted,
|
||
startFeeds() {
|
||
if (this.mode !== "local") return
|
||
this.monitorFeed.start()
|
||
this.modelFeed.start()
|
||
this.softwareFeed.start()
|
||
this.driveFeed.start()
|
||
},
|
||
stopFeeds() {
|
||
this.monitorFeed.stop()
|
||
this.modelFeed.stop()
|
||
this.softwareFeed.stop()
|
||
this.driveFeed.stop()
|
||
this.monitor.snapshot = null
|
||
this.monitor.status = "idle"
|
||
},
|
||
ignoreDrive(drive) {
|
||
if (this.mode !== "local" || document.hidden || this.drives.busy) return
|
||
return this.driveFeed.ignore(drive.routeId, !drive.ignored)
|
||
},
|
||
},
|
||
template: `
|
||
<div class="gx-view gx-home">
|
||
<div class="gx-home__hero">
|
||
<div><h1>Dashboard</h1><p class="gx-note">Your saved drives and current device</p></div>
|
||
|
||
</div>
|
||
<GxState v-if="mode !== 'local'">Dashboard information is available on your device.</GxState>
|
||
<template v-else>
|
||
<section class="gx-card gx-home__last">
|
||
<div class="gx-home__kicker"><span></span> Last drive</div>
|
||
<p v-if="drives.status !== 'ready'" role="status">{{ drives.status === 'loading' ? 'Loading your drives…' : drives.error || 'Drive history is unavailable.' }}</p>
|
||
<p v-else-if="!driving.last">No saved drives yet.</p>
|
||
<template v-else>
|
||
<p class="gx-home__date">{{ driving.last.date }}</p>
|
||
<p v-if="!driving.last.complete" class="gx-home__subtle">{{ driving.last.analysisLabel }}{{ driving.last.analysisError ? ": " + driving.last.analysisError : "" }}</p>
|
||
<div class="gx-home__four"><div><strong>{{ driving.last.distance }}</strong><span>distance</span></div>
|
||
<div><strong>{{ driving.last.duration }}</strong><span>duration</span></div>
|
||
<div><strong>{{ driving.last.engaged }}</strong><span>engaged</span></div>
|
||
<div><strong>{{ driving.last.average }}</strong><span>average speed</span></div></div>
|
||
<p class="gx-home__subtle">{{ driving.last.aol }} AOL steering only · {{ driving.last.aolDuration }}</p>
|
||
<div class="gx-home__attention"><span class="gx-home__model-tag"><i class="bi bi-cpu"></i> {{ driving.last.model }}</span><span><i class="bi bi-eye"></i> {{ driving.last.distracted === "Unavailable" ? "Attention data unavailable" : driving.last.distracted + " distracted " + (driving.last.distracted === "1" ? "moment" : "moments") }}</span>
|
||
<span><i class="bi bi-exclamation-triangle"></i> {{ driving.last.unresponsive }} unresponsive {{ driving.last.unresponsive === "1" ? "moment" : "moments" }}</span></div>
|
||
</template>
|
||
</section>
|
||
<template v-if="driving">
|
||
<p v-if="driving.analysis.pending" class="gx-home__analysis" role="status">
|
||
<i class="bi bi-hourglass-split"></i> {{ driving.analysis.running ? 'Updating drive history' : driving.analysis.state === 'waitingParked' ? 'Drive history will update when parked' : 'Drive history queued' }}{{ driving.analysis.pending ? ' · ' + driving.analysis.pending + ' remaining' : '' }}
|
||
</p>
|
||
<div class="gx-home__section-title"><span></span>Your driving</div>
|
||
<div class="gx-home__totals">
|
||
<section class="gx-card"><h2>Saved History</h2><strong>{{ driving.totals.distance }}</strong><span>{{ counted(driving.totals.drives, "drive") }}</span></section>
|
||
<section class="gx-card"><h2>Time on the Road</h2><strong>{{ driving.totals.duration }}</strong><span>{{ driving.totals.engaged }} engaged</span><span>{{ driving.totals.aol }} AOL steering only · {{ driving.totals.aolDrives }}/{{ driving.totals.drives }} drives measured</span></section>
|
||
<section class="gx-card"><h2>This Week</h2><strong>{{ driving.week.distance }}</strong><span>{{ counted(driving.week.drives, "drive") }} · {{ driving.week.duration }}</span></section>
|
||
</div>
|
||
<div class="gx-home__pair">
|
||
<section class="gx-card gx-home__week"><div class="gx-section__header"><i class="bi bi-calendar-week"></i><h2 class="gx-section__title">This Week</h2></div>
|
||
<div class="gx-home__week-top"><div class="gx-home__donut" :style="{ '--gx-home-progress': driving.week.progress + '%' }">
|
||
<strong>{{ driving.week.engaged }}</strong><span>engaged</span></div>
|
||
<div><strong>{{ driving.week.distance }}</strong><span>across {{ counted(driving.week.drives, "drive") }}</span></div></div>
|
||
<p class="gx-home__subtle">{{ driving.week.aol }} AOL steering only · {{ driving.week.aolDrives }}/{{ driving.week.drives }} drives measured</p>
|
||
<h3>Distance per day · {{ driving.week.timezone }}</h3>
|
||
<div class="gx-home__bars"><div v-for="day in driving.week.days" :key="day.date" :aria-label="day.date + ': ' + day.distance" :title="day.date + ': ' + day.distance">
|
||
<div class="gx-home__bar-track"><span :style="{ height: day.height + '%' }"></span></div><small>{{ day.label }}</small><small>{{ day.distance }}</small></div></div>
|
||
</section>
|
||
<section class="gx-card gx-home__records"><div class="gx-section__header"><i class="bi bi-trophy"></i><h2 class="gx-section__title">Personal Records</h2></div>
|
||
<p v-if="!driving.records.length" class="gx-home__subtle">Records will appear as drives are saved.</p>
|
||
<div v-for="record in driving.records" :key="record.id" class="gx-home__record"><span class="gx-home__record-art" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path v-for="(path, index) in recordArt(record.id)" :key="index" :d="path" /></svg></span><span class="gx-home__record-label">{{ record.label }}</span><strong>{{ record.display }}</strong></div>
|
||
</section>
|
||
</div>
|
||
<GxNotice tone="danger" v-if="driving.analysis.failed">{{ driving.analysis.failed }} drive analysis failure{{ driving.analysis.failed === 1 ? '' : 's' }}{{ driving.analysis.quarantined ? ' · automatic retries paused for ' + driving.analysis.quarantined : '' }}.
|
||
<div v-for="failure in driving.analysis.failures" :key="failure.routeId">{{ failure.routeId }}: {{ failure.reason }}</div>
|
||
</GxNotice>
|
||
<section class="gx-card gx-home__recent"><div class="gx-section__header"><i class="bi bi-clock-history"></i><h2 class="gx-section__title">Recent Drives</h2></div>
|
||
<p v-if="!driving.recent.length" class="gx-home__subtle">No saved drives yet.</p>
|
||
<div v-for="drive in driving.recent" :key="drive.routeId" class="gx-home__drive" :class="{ 'is-ignored': drive.ignored }">
|
||
<div><strong>{{ drive.date }}</strong><small>{{ drive.model }}</small></div>
|
||
<div><strong>{{ drive.complete ? drive.distance : drive.analysisLabel }}</strong><small>{{ drive.duration }} · {{ drive.engaged }} engaged</small><small>{{ drive.aol }} AOL steering only</small></div>
|
||
<div><small>{{ drive.ignored ? 'Excluded from totals' : drive.complete ? 'Included in totals' : drive.analysisError || drive.analysisLabel }}</small>
|
||
<button type="button" class="gx-home__link" :inert="drives.busy" :aria-busy="drives.busy || undefined" @click="ignoreDrive(drive)">
|
||
{{ drive.ignored ? 'Include in stats' : 'Ignore drive stats' }}</button></div>
|
||
</div>
|
||
<GxNotice tone="danger" v-if="drives.error">{{ drives.error }}</GxNotice>
|
||
</section>
|
||
<section class="gx-card gx-home__models"><div class="gx-section__header"><i class="bi bi-cpu"></i><h2 class="gx-section__title">Recorded Models</h2></div>
|
||
<p v-if="!driving.models.length" class="gx-home__subtle">Recorded models will appear as drives are saved.</p>
|
||
<div v-else class="gx-home__model-layout"><div class="gx-home__model-donut" :style="driving.modelStyle"></div>
|
||
<div><div v-for="model in driving.models" :key="model.name" class="gx-home__model-row">
|
||
<span class="gx-home__swatch" :style="{ background: model.color }"></span>
|
||
<div><strong>{{ model.name }}</strong><small>{{ counted(model.drives, "drive") }} · {{ model.distance }}</small></div></div></div></div>
|
||
</section>
|
||
</template>
|
||
<div class="gx-home__section-title"><span></span>Your device</div>
|
||
<LocalAccess ref="localAccess" :mode="mode" :on-unauthorized="unauthorized" />
|
||
<div class="gx-home__grid">
|
||
<section class="gx-card gx-home__card">
|
||
<div class="gx-section__header"><i class="bi bi-activity"></i><h2 class="gx-section__title">System</h2></div>
|
||
<p v-if="!summary.system.available" role="status">Live system status unavailable.</p>
|
||
<template v-else><div class="gx-home__metrics"><div><strong>{{ summary.system.cpu }}</strong><span>CPU</span></div>
|
||
<div><strong>{{ summary.system.memory }}</strong><span>Memory</span></div></div>
|
||
<p>Storage: {{ summary.system.storage }}</p><small>Sampled {{ summary.system.observed }}</small></template>
|
||
<button type="button" class="gx-home__link" @click="go('/logs/monitor')">Open System Monitor <i class="bi bi-arrow-right"></i></button>
|
||
</section>
|
||
<section class="gx-card gx-home__card">
|
||
<div class="gx-section__header"><i class="bi bi-cpu"></i><h2 class="gx-section__title">Driving model</h2></div>
|
||
<p v-if="!summary.model.available" role="status">Model identity unavailable.</p>
|
||
<template v-else><p>Reported health: <strong>{{ summary.model.health }}</strong></p>
|
||
<p>Loaded variant: <strong>{{ summary.model.variant }}</strong></p>
|
||
<small v-if="summary.model.fallback">{{ summary.model.fallback }}</small></template>
|
||
<button type="button" class="gx-home__link" @click="go('/manage_models')">Open Driving Model <i class="bi bi-arrow-right"></i></button>
|
||
</section>
|
||
<section class="gx-card gx-home__card">
|
||
<div class="gx-section__header"><i class="bi bi-arrow-repeat"></i><h2 class="gx-section__title">Software</h2></div>
|
||
<p v-if="!summary.software.available" role="status">Installed software report unavailable.</p>
|
||
<template v-else><p>Installed: <strong>{{ summary.software.version }}</strong></p>
|
||
<p>Branch: <strong>{{ summary.software.branch }}</strong></p>
|
||
<p>Last updater report: <strong>{{ summary.software.update }}</strong></p></template>
|
||
<button type="button" class="gx-home__link" @click="go('/system')">Open Software & Updates <i class="bi bi-arrow-right"></i></button>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
</div>`,
|
||
}
|