Partially revert #3661 - just keep the real errors wrapped, but don't display anything to the user (#3677)

* Revert "Remove unused RocketIcon"

This reverts commit c5e8d0c172.

* Revert "Display either hash or actual error message"

This reverts commit 0c091ab35f.

* Revert "Use ApiErrorNotice in funnels"

This reverts commit 5929de248e.

* Revert "Don't render "No data yet" when there's a NetworkError for example"

This reverts commit 70bee07632.

* Revert "Show the sinking shuttle notice whenever an API error occurs"

This reverts commit 9a62c8af2b.

* Revert "Add Hahash dependency"

This reverts commit b94207ea0a.

* Remove support hash
This commit is contained in:
hq1 2024-01-09 17:17:42 +01:00 committed by GitHub
parent 403f559b35
commit c1a1d697a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 100 additions and 191 deletions

View File

@ -1,6 +1,4 @@
import {formatISO} from './util/date' import {formatISO} from './util/date'
import React from 'react';
import RocketIcon from './stats/modals/rocket-icon'
let abortController = new AbortController() let abortController = new AbortController()
let SHARED_LINK_AUTH = null let SHARED_LINK_AUTH = null
@ -13,23 +11,6 @@ class ApiError extends Error {
} }
} }
export function ApiErrorNotice({ error }) {
return (
<div>
<div className="text-center text-gray-900 dark:text-gray-100 mt-16 mb-16">
<RocketIcon />
<div className="text-lg font-bold">Oops! Our servers had trouble retrieving your data.</div>
<div className="text-xs mt-4">If the problem persists after refreshing your browser, please <a rel="noreferrer" target="_blank" href="https://plausible.io/contact" className="underline text-indigo-400">contact support</a> with the following code:
</div>
<div className="mt-4 text-xs font-mono">
{!error.payload && error.message}
{error.payload && error.payload.support_hash}
</div>
</div>
</div>
);
};
function serialize(obj) { function serialize(obj) {
var str = []; var str = [];
for (var p in obj) for (var p in obj)

View File

@ -5,11 +5,13 @@ import FunnelTooltip from './funnel-tooltip.js'
import ChartDataLabels from 'chartjs-plugin-datalabels' import ChartDataLabels from 'chartjs-plugin-datalabels'
import numberFormatter from '../util/number-formatter' import numberFormatter from '../util/number-formatter'
import Bar from '../stats/bar' import Bar from '../stats/bar'
import { ApiErrorNotice } from '../api'
import RocketIcon from '../stats/modals/rocket-icon'
import * as api from '../api' import * as api from '../api'
import LazyLoader from '../components/lazy-loader' import LazyLoader from '../components/lazy-loader'
export default function Funnel(props) { export default function Funnel(props) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
@ -273,7 +275,12 @@ export default function Funnel(props) {
return ( return (
<> <>
{header()} {header()}
<ApiErrorNotice error={error} /> <div className="text-center text-gray-900 dark:text-gray-100 mt-16">
<RocketIcon />
<div className="text-lg font-bold">Oops! Something went wrong</div>
<div className="text-lg">{error.message ? error.message : 'Failed to render funnel'}</div>
<div className="text-xs mt-8">Please try refreshing your browser or selecting the funnel again.</div>
</div>
</> </>
) )
} }

View File

@ -2,21 +2,15 @@ import React from 'react';
import * as api from '../../api' import * as api from '../../api'
import * as url from '../../util/url' import * as url from '../../util/url'
import { escapeFilterValue } from '../../util/filters' import { escapeFilterValue } from '../../util/filters'
import { ApiErrorNotice } from '../../api'
import { CR_METRIC } from '../reports/metrics'; import { CR_METRIC } from '../reports/metrics';
import ListReport from '../reports/list'; import ListReport from '../reports/list';
export default function Conversions(props) { export default function Conversions(props) {
const { site, query } = props const { site, query } = props
const [error, setError] = React.useState(undefined)
function fetchConversions() { function fetchConversions() {
return api.get(url.apiPath(site, '/conversions'), query, { limit: 9 }) return api.get(url.apiPath(site, '/conversions'), query, { limit: 9 })
.catch((err) => {
setError(err)
})
} }
function getFilterFor(listItem) { function getFilterFor(listItem) {
@ -24,11 +18,6 @@ export default function Conversions(props) {
} }
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
if (error) {
return (
<ApiErrorNotice error={error} />
)
} else {
return ( return (
<ListReport <ListReport
fetchData={fetchConversions} fetchData={fetchConversions}
@ -50,4 +39,3 @@ export default function Conversions(props) {
/> />
) )
} }
}

View File

@ -6,13 +6,13 @@ import * as url from '../../util/url'
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics"; import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
import * as storage from "../../util/storage"; import * as storage from "../../util/storage";
import { parsePrefix, escapeFilterValue } from "../../util/filters" import { parsePrefix, escapeFilterValue } from "../../util/filters"
import { ApiErrorNotice } from '../../api'
export default function Properties(props) { export default function Properties(props) {
const { site, query } = props const { site, query } = props
const propKeyStorageName = `prop_key__${site.domain}` const propKeyStorageName = `prop_key__${site.domain}`
const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}` const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}`
const [error, setError] = useState(undefined)
const [propKey, setPropKey] = useState(choosePropKey()) const [propKey, setPropKey] = useState(choosePropKey())
useEffect(() => { useEffect(() => {
@ -48,17 +48,11 @@ export default function Properties(props) {
function fetchProps() { function fetchProps() {
return api.get(url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), query) return api.get(url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), query)
.catch((err) => {
setError(err)
})
} }
const fetchPropKeyOptions = useCallback(() => { const fetchPropKeyOptions = useCallback(() => {
return (input) => { return (input) => {
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() }) return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
.catch((err) => {
setError(err)
})
} }
}, [query]) }, [query])
@ -77,11 +71,6 @@ export default function Properties(props) {
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
function renderBreakdown() { function renderBreakdown() {
if (error) {
return (
<ApiErrorNotice error={error} />
)
} else {
return ( return (
<ListReport <ListReport
fetchData={fetchProps} fetchData={fetchProps}
@ -102,16 +91,11 @@ export default function Properties(props) {
/> />
) )
} }
}
const getFilterFor = (listItem) => { return { 'props': JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }) } } const getFilterFor = (listItem) => { return { 'props': JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }) } }
const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : [] const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500' const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500'
if (error) {
return (<ApiErrorNotice error={error} />)
} else {
return ( return (
<div className="w-full mt-4"> <div className="w-full mt-4">
<div> <div>
@ -121,4 +105,3 @@ export default function Properties(props) {
</div> </div>
) )
} }
}

View File

@ -10,8 +10,6 @@ import MoreLink from '../more-link'
import * as api from '../../api' import * as api from '../../api'
import { navigateToQuery } from '../../query' import { navigateToQuery } from '../../query'
import { ApiErrorNotice } from '../../api'
class Countries extends React.Component { class Countries extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -77,7 +75,6 @@ class Countries extends React.Component {
fetchCountries() { fetchCountries() {
return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query, {limit: 300}) return api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query, {limit: 300})
.then((res) => this.setState({loading: false, countries: res})) .then((res) => this.setState({loading: false, countries: res}))
.catch((err) => this.setState({ loading: false, error: err }))
} }
resizeMap() { resizeMap() {
@ -162,7 +159,6 @@ class Countries extends React.Component {
return ( return (
<LazyLoader onVisible={this.onVisible}> <LazyLoader onVisible={this.onVisible}>
{ this.state.loading && <div className="mx-auto my-32 loading"><div></div></div> } { this.state.loading && <div className="mx-auto my-32 loading"><div></div></div> }
{this.state.error && <ApiErrorNotice error={this.state.error} />}
<FadeIn show={!this.state.loading}> <FadeIn show={!this.state.loading}>
{ this.renderBody() } { this.renderBody() }
</FadeIn> </FadeIn>

View File

@ -8,7 +8,6 @@ import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter' import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import { escapeFilterValue } from '../../util/filters' import { escapeFilterValue } from '../../util/filters'
import { ApiErrorNotice } from '../../api'
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
/*global require*/ /*global require*/
@ -25,7 +24,7 @@ const Money = maybeRequire().default
function ConversionsModal(props) { function ConversionsModal(props) {
const site = props.site const site = props.site
const query = parseQuery(props.location.search, site) const query = parseQuery(props.location.search, site)
const [error, setError] = useState(undefined)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@ -43,9 +42,6 @@ function ConversionsModal(props) {
setPage(page + 1) setPage(page + 1)
setMoreResultsAvailable(res.length >= 100) setMoreResultsAvailable(res.length >= 100)
}) })
.catch((err) => {
setError(err)
})
} }
function loadMore() { function loadMore() {
@ -124,8 +120,7 @@ function ConversionsModal(props) {
return ( return (
<Modal site={site}> <Modal site={site}>
{renderBody()} {renderBody()}
{error && <ApiErrorNotice error={error} />} {loading && renderLoading()}
{!error && loading && renderLoading()}
{!loading && moreResultsAvailable && renderLoadMore()} {!loading && moreResultsAvailable && renderLoadMore()}
</Modal> </Modal>
) )

View File

@ -8,8 +8,6 @@ import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import { trimURL } from '../../util/url' import { trimURL } from '../../util/url'
import { ApiErrorNotice } from '../../api'
class EntryPagesModal extends React.Component { class EntryPagesModal extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -18,8 +16,7 @@ class EntryPagesModal extends React.Component {
query: parseQuery(props.location.search, props.site), query: parseQuery(props.location.search, props.site),
pages: [], pages: [],
page: 1, page: 1,
moreResultsAvailable: false, moreResultsAvailable: false
error: undefined
} }
} }
@ -42,7 +39,6 @@ class EntryPagesModal extends React.Component {
moreResultsAvailable: res.length === 100 moreResultsAvailable: res.length === 100
})) }))
) )
.catch((err) => this.setState({ loading: false, error: err }))
} }
loadMore() { loadMore() {
@ -154,7 +150,6 @@ class EntryPagesModal extends React.Component {
return ( return (
<Modal site={this.props.site}> <Modal site={this.props.site}>
{this.renderBody()} {this.renderBody()}
{this.state.error && <ApiErrorNotice error={this.state.error} />}
{this.renderLoading()} {this.renderLoading()}
</Modal> </Modal>
) )

View File

@ -7,9 +7,6 @@ import * as api from '../../api'
import numberFormatter from '../../util/number-formatter' import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import { trimURL } from '../../util/url' import { trimURL } from '../../util/url'
import { ApiErrorNotice } from '../../api'
class ExitPagesModal extends React.Component { class ExitPagesModal extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -18,8 +15,7 @@ class ExitPagesModal extends React.Component {
query: parseQuery(props.location.search, props.site), query: parseQuery(props.location.search, props.site),
pages: [], pages: [],
page: 1, page: 1,
moreResultsAvailable: false, moreResultsAvailable: false
error: undefined
} }
} }
@ -32,7 +28,6 @@ class ExitPagesModal extends React.Component {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, query, { limit: 100, page }) api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, query, { limit: 100, page })
.then((res) => this.setState((state) => ({ loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100 }))) .then((res) => this.setState((state) => ({ loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100 })))
.catch((err) => this.setState({ loading: false, error: err }))
} }
loadMore() { loadMore() {
@ -132,7 +127,6 @@ class ExitPagesModal extends React.Component {
return ( return (
<Modal site={this.props.site}> <Modal site={this.props.site}>
{this.renderBody()} {this.renderBody()}
{this.state.error && <ApiErrorNotice error={this.state.error} />}
{this.renderLoading()} {this.renderLoading()}
</Modal> </Modal>
) )

View File

@ -8,8 +8,6 @@ import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import { trimURL } from '../../util/url' import { trimURL } from '../../util/url'
import { ApiErrorNotice } from '../../api'
class PagesModal extends React.Component { class PagesModal extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -18,8 +16,7 @@ class PagesModal extends React.Component {
query: parseQuery(props.location.search, props.site), query: parseQuery(props.location.search, props.site),
pages: [], pages: [],
page: 1, page: 1,
moreResultsAvailable: false, moreResultsAvailable: false
error: undefined
} }
} }
@ -33,7 +30,6 @@ class PagesModal extends React.Component {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, { limit: 100, page, detailed }) api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, { limit: 100, page, detailed })
.then((res) => this.setState((state) => ({ loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100 }))) .then((res) => this.setState((state) => ({ loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100 })))
.catch((err) => this.setState({ loading: false, error: err }))
} }
loadMore() { loadMore() {
@ -140,7 +136,6 @@ class PagesModal extends React.Component {
return ( return (
<Modal site={this.props.site}> <Modal site={this.props.site}>
{this.renderBody()} {this.renderBody()}
{this.state.error && <ApiErrorNotice error={this.state.error} />}
{this.renderLoading()} {this.renderLoading()}
</Modal> </Modal>
) )

View File

@ -9,7 +9,6 @@ import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions"; import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
import { escapeFilterValue } from "../../util/filters" import { escapeFilterValue } from "../../util/filters"
import { ApiErrorNotice } from '../../api'
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
/*global require*/ /*global require*/
@ -27,7 +26,7 @@ function PropsModal(props) {
const site = props.site const site = props.site
const query = parseQuery(props.location.search, site) const query = parseQuery(props.location.search, site)
const propKey = props.location.pathname.split('/').pop() const propKey = props.location.pathname.split('/').pop()
const [error, setError] = useState(undefined)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@ -45,9 +44,6 @@ function PropsModal(props) {
setPage(page + 1) setPage(page + 1)
setMoreResultsAvailable(res.length >= 100) setMoreResultsAvailable(res.length >= 100)
}) })
.catch((err) => {
setError(err)
})
} }
function loadMore() { function loadMore() {
@ -127,8 +123,7 @@ function PropsModal(props) {
return ( return (
<Modal site={site}> <Modal site={site}>
{renderBody()} {renderBody()}
{error && <ApiErrorNotice error={error} />} {loading && renderLoading()}
{!error && loading && renderLoading()}
{!loading && moreResultsAvailable && renderLoadMore()} {!loading && moreResultsAvailable && renderLoadMore()}
</Modal> </Modal>
) )

View File

@ -6,8 +6,6 @@ import * as api from '../../api'
import numberFormatter, { durationFormatter } from '../../util/number-formatter' import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import { ApiErrorNotice } from '../../api'
const TITLES = { const TITLES = {
sources: 'Top Sources', sources: 'Top Sources',
utm_mediums: 'Top UTM mediums', utm_mediums: 'Top UTM mediums',
@ -25,8 +23,7 @@ class SourcesModal extends React.Component {
sources: [], sources: [],
query: parseQuery(props.location.search, props.site), query: parseQuery(props.location.search, props.site),
page: 1, page: 1,
moreResultsAvailable: false, moreResultsAvailable: false
error: undefined
} }
} }
@ -37,7 +34,6 @@ class SourcesModal extends React.Component {
const detailed = this.showExtra() const detailed = this.showExtra()
api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentFilter()}`, query, { limit: 100, page, detailed }) api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentFilter()}`, query, { limit: 100, page, detailed })
.then((res) => this.setState({ loading: false, sources: sources.concat(res), moreResultsAvailable: res.length === 100 })) .then((res) => this.setState({ loading: false, sources: sources.concat(res), moreResultsAvailable: res.length === 100 }))
.catch((err) => this.setState({ loading: false, error: err }))
} }
componentDidMount() { componentDidMount() {
@ -175,7 +171,6 @@ class SourcesModal extends React.Component {
</main> </main>
{this.renderLoading()} {this.renderLoading()}
{this.state.error && <ApiErrorNotice error={this.state.error} />}
</Modal> </Modal>
) )
} }

View File

@ -6,22 +6,18 @@ import * as api from '../../api'
import numberFormatter from '../../util/number-formatter' import numberFormatter from '../../util/number-formatter'
import {parseQuery} from '../../query' import {parseQuery} from '../../query'
import { ApiErrorNotice } from '../../api'
class ModalTable extends React.Component { class ModalTable extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
loading: true, loading: true,
query: parseQuery(props.location.search, props.site), query: parseQuery(props.location.search, props.site)
error: undefined
} }
} }
componentDidMount() { componentDidMount() {
api.get(this.props.endpoint, this.state.query, {limit: 100}) api.get(this.props.endpoint, this.state.query, {limit: 100})
.then((res) => this.setState({loading: false, list: res})) .then((res) => this.setState({loading: false, list: res}))
.catch((err) => this.setState({ loading: false, error: err }))
} }
label() { label() {
@ -100,7 +96,6 @@ class ModalTable extends React.Component {
render() { render() {
return ( return (
<Modal site={this.props.site} show={!this.state.loading}> <Modal site={this.props.site} show={!this.state.loading}>
{this.state.error && <ApiErrorNotice error={this.state.error} />}
{ this.renderBody() } { this.renderBody() }
</Modal> </Modal>
) )

View File

@ -9,8 +9,6 @@ import Bar from '../bar'
import LazyLoader from '../../components/lazy-loader' import LazyLoader from '../../components/lazy-loader'
import classNames from 'classnames' import classNames from 'classnames'
import { trimURL } from '../../util/url' import { trimURL } from '../../util/url'
import { ApiErrorNotice } from '../../api'
const MAX_ITEMS = 9 const MAX_ITEMS = 9
const MIN_HEIGHT = 380 const MIN_HEIGHT = 380
const ROW_HEIGHT = 32 const ROW_HEIGHT = 32
@ -124,8 +122,6 @@ export default function ListReport(props) {
} }
props.fetchData() props.fetchData()
.then((res) => setState({ loading: false, list: res })) .then((res) => setState({ loading: false, list: res }))
.catch((err) => setState({ loading: false, error: err }))
}, [props.keyLabel, props.query]) }, [props.keyLabel, props.query])
const onVisible = () => { setVisible(true) } const onVisible = () => { setVisible(true) }
@ -180,9 +176,8 @@ export default function ListReport(props) {
{maybeRenderDetailsLink()} {maybeRenderDetailsLink()}
</div> </div>
) )
} else if (!state.error) {
return renderNoDataYet()
} }
return renderNoDataYet()
} }
function renderReportHeader() { function renderReportHeader() {
@ -319,7 +314,6 @@ export default function ListReport(props) {
<LazyLoader onVisible={onVisible} > <LazyLoader onVisible={onVisible} >
<div className="w-full" style={{ minHeight: `${MIN_HEIGHT}px` }}> <div className="w-full" style={{ minHeight: `${MIN_HEIGHT}px` }}>
{state.loading && renderLoading()} {state.loading && renderLoading()}
{state.error && <ApiErrorNotice error={state.error} />}
{!state.loading && <FadeIn show={!state.loading} className="h-full"> {!state.loading && <FadeIn show={!state.loading} className="h-full">
{renderReport()} {renderReport()}
</FadeIn>} </FadeIn>}

View File

@ -13,9 +13,7 @@ defmodule PlausibleWeb.Plugs.ErrorHandler do
@impl Plug.ErrorHandler @impl Plug.ErrorHandler
def handle_errors(conn, %{kind: kind, reason: reason}) do def handle_errors(conn, %{kind: kind, reason: reason}) do
hash = Hahash.name({kind, reason}) json(conn, %{error: "internal server error"})
Sentry.Context.set_tags_context(%{hash: hash})
json(conn, %{error: "internal server error", support_hash: hash})
end end
end end
end end

View File

@ -136,8 +136,7 @@ defmodule Plausible.MixProject do
{:scrivener_ecto, "~> 2.0"}, {:scrivener_ecto, "~> 2.0"},
{:esbuild, "~> 0.7", runtime: Mix.env() in [:dev, :small_dev]}, {:esbuild, "~> 0.7", runtime: Mix.env() in [:dev, :small_dev]},
{:tailwind, "~> 0.2.0", runtime: Mix.env() in [:dev, :small_dev]}, {:tailwind, "~> 0.2.0", runtime: Mix.env() in [:dev, :small_dev]},
{:ex_json_logger, "~> 1.3.0"}, {:ex_json_logger, "~> 1.3.0"}
{:hahash, "~> 0.2.0"}
] ]
end end

View File

@ -61,7 +61,6 @@
"gproc": {:hex, :gproc, "0.8.0", "cea02c578589c61e5341fce149ea36ccef236cc2ecac8691fba408e7ea77ec2f", [:rebar3], [], "hexpm", "580adafa56463b75263ef5a5df4c86af321f68694e7786cb057fd805d1e2a7de"}, "gproc": {:hex, :gproc, "0.8.0", "cea02c578589c61e5341fce149ea36ccef236cc2ecac8691fba408e7ea77ec2f", [:rebar3], [], "hexpm", "580adafa56463b75263ef5a5df4c86af321f68694e7786cb057fd805d1e2a7de"},
"grpcbox": {:hex, :grpcbox, "0.16.0", "b83f37c62d6eeca347b77f9b1ec7e9f62231690cdfeb3a31be07cd4002ba9c82", [:rebar3], [{:acceptor_pool, "~>1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~>0.13.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~>0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~>0.8.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "294df743ae20a7e030889f00644001370a4f7ce0121f3bbdaf13cf3169c62913"}, "grpcbox": {:hex, :grpcbox, "0.16.0", "b83f37c62d6eeca347b77f9b1ec7e9f62231690cdfeb3a31be07cd4002ba9c82", [:rebar3], [{:acceptor_pool, "~>1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~>0.13.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~>0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~>0.8.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "294df743ae20a7e030889f00644001370a4f7ce0121f3bbdaf13cf3169c62913"},
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"hahash": {:hex, :hahash, "0.2.0", "5d21c44b1b65d1f591adfd20ea4720e8a81db3caa377c28b20b434b8afc2115f", [:mix], [], "hexpm", "c39ce3f6163bbcf668e7a0bef88b608a9f37f99fcf181e7a17e4d10d02a5fbbd"},
"heroicons": {:hex, :heroicons, "0.5.3", "ee8ae8335303df3b18f2cc07f46e1cb6e761ba4cf2c901623fbe9a28c0bc51dd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "a210037e8a09ac17e2a0a0779d729e89c821c944434c3baa7edfc1f5b32f3502"}, "heroicons": {:hex, :heroicons, "0.5.3", "ee8ae8335303df3b18f2cc07f46e1cb6e761ba4cf2c901623fbe9a28c0bc51dd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "a210037e8a09ac17e2a0a0779d729e89c821c944434c3baa7edfc1f5b32f3502"},
"hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm", "06f580167c4b8b8a6429040df36cc93bba6d571faeaec1b28816523379cbb23a"}, "hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm", "06f580167c4b8b8a6429040df36cc93bba6d571faeaec1b28816523379cbb23a"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},