Merge branch 'auto_sync' of https://github.com/rickcarlino/Farmbot-Web-App into auto_sync

This commit is contained in:
Rick Carlino 2017-11-01 12:19:29 -05:00
commit 57e4f818e8
25 changed files with 342 additions and 128 deletions

View file

@ -25,6 +25,7 @@ gem "webpack-rails"
# vars if you wish to use them on your own servers.
gem "rollbar"
gem "skylight", "1.4.0"
gem "sneakers"
group :development, :test do
gem "codecov", require: false

View file

@ -60,9 +60,12 @@ GEM
tzinfo (~> 1.1)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
amq-protocol (2.2.0)
arel (8.0.0)
bcrypt (3.1.11)
builder (3.2.3)
bunny (2.7.1)
amq-protocol (>= 2.2.0)
case_transform (0.2)
activesupport
choice (0.2.0)
@ -228,6 +231,9 @@ GEM
rspec-support (~> 3.5.0)
rspec-support (3.5.0)
ruby-graphviz (1.2.3)
serverengine (1.5.11)
sigdump (~> 0.2.2)
sigdump (0.2.4)
simplecov (0.15.1)
docile (~> 1.1.0)
json (>= 1.8, < 3)
@ -235,6 +241,11 @@ GEM
simplecov-html (0.10.2)
skylight (1.4.0)
activesupport (>= 3.0.0)
sneakers (2.6.0)
bunny (~> 2.7.0)
concurrent-ruby (~> 1.0)
serverengine (~> 1.5.11)
thor
sprockets (3.7.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
@ -293,6 +304,7 @@ DEPENDENCIES
simplecov
skylight (= 1.4.0)
smarf_doc!
sneakers
thin
tzinfo
webpack-rails
@ -301,4 +313,4 @@ RUBY VERSION
ruby 2.4.2p198
BUNDLED WITH
1.15.4
1.16.0.pre.3

View file

@ -89,7 +89,13 @@ private
end
def current_device
@current_device ||= (current_user.try(:device) || no_device)
if @current_device
@current_device
else
@current_device = (current_user.try(:device) || no_device)
Thread.current[:device] = @current_device # Mutable state eww
@current_device
end
end
def no_device

View file

@ -1,3 +1,44 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
after_save :maybe_broadcast, on: [:create, :update]
after_destroy :maybe_broadcast
DONT_BROADCAST = [ "created_at",
"last_sign_in_at",
"last_sign_in_ip",
"sign_in_count",
"updated_at",
"current_sign_in_at" ]
# Determine if the changes to the model are worth broadcasting or not.
# Reduces network noise.
def notable_changes?
!self.saved_changes.except(*self.class::DONT_BROADCAST).empty?
end
def broadcast?
Device.current && (destroyed? || notable_changes?)
end
def maybe_broadcast
self.broadcast! if broadcast?
end
def broadcast_payload
{
args: {
label: (Device.current_jwt || {})[:jti] || "UNKNOWN"
},
body: (destroyed? ? nil : self).as_json
}.to_json
end
def chan_name
"sync.#{self.class.name}.#{self.id}"
end
def broadcast!
# Thread.new { `play ~/tada.wav` }
Transport.send(broadcast_payload, Device.current.id, chan_name)
end
end

View file

@ -28,4 +28,24 @@ class Device < ApplicationRecord
def auth_token
SessionToken.as_json(self.users.first)[:token].encoded
end
# Send a realtime message to a logged in user.
def tell(message, chan = "toast")
log = Log.new({ device: self,
message: message,
created_at: Time.now,
channels: [chan],
meta: { type: "info" } })
json = LogSerializer.new(log).as_json.to_json
Transport.send(json, self.id, "logs")
end
def self.current
Thread.current[:device]
end
def self.current_jwt
Thread.current[:jwt]
end
end

View file

@ -18,4 +18,8 @@ class LogDispatch < ApplicationRecord
.log_digest(device)
.deliver_later
end
def broadcast?
false
end
end

View file

@ -9,4 +9,8 @@ class SequenceDependency < ApplicationRecord
belongs_to :sequence
validates_presence_of :sequence
belongs_to :dependency, polymorphic: true
def broadcast?
false
end
end

View file

@ -1,4 +1,7 @@
# Keeps track of JSON Web Tokens that have been revoked prior to their
# expiration date.
class TokenExpiration < ApplicationRecord
def broadcast?
false
end
end

25
app/models/transport.rb Normal file
View file

@ -0,0 +1,25 @@
# A wrapper around AMQP to stay DRY. Will make life easier if we ever need to
# change protocols
module Transport
AMQP_URL = ENV['CLOUDAMQP_URL'] ||
ENV['RABBITMQ_URL'] ||
"amqp://guest:guest@localhost:5672"
def self.connection
@connection ||= Bunny
.new(AMQP_URL, read_timeout: 10, heartbeat: 10)
.start
end
def self.topic
@topic ||= self
.connection
.create_channel
.topic("amq.topic", auto_delete: true)
end
def self.send(message, id, channel)
topic.publish(message, routing_key: "bot.device_#{id}.#{channel}")
end
end

View file

@ -7,7 +7,9 @@ module Auth
def execute
token = SessionToken.decode!(just_the_token)
claims = token.unencoded
User.find_by_email_or_id(claims["sub"])
Thread.current[:jwt] = claims.deep_symbolize_keys
User
.find_by_email_or_id(claims["sub"])
rescue JWT::DecodeError, ActiveRecord::RecordNotFound, User::BadSub
add_error :jwt, :decode_error, Auth::ReloadToken::BAD_SUB
end

View file

@ -8,7 +8,7 @@ Bundler.require(:default, Rails.env)
module FarmBot
class Application < Rails::Application
config.active_job.queue_adapter = :delayed_job
config.active_job.queue_adapter = :sneakers
config.action_dispatch.perform_deep_munge = false
I18n.enforce_available_locales = false
config.generators do |g|
@ -34,5 +34,10 @@ module FarmBot
Rails.application.routes.default_url_options[:port] = ENV["API_PORT"] || 3000
# ¯\_(ツ)_/¯
$API_URL = "//#{ Rails.application.routes.default_url_options[:host] }:#{ Rails.application.routes.default_url_options[:port] }"
Sneakers.configure(amqp: 'amqp://localhost',
vhost: '/',
exchange: 'sneakers',
exchange_type: :direct)
end
end

View file

@ -138,7 +138,6 @@ result.push(enum_type :LegalArgString, HASH[:args].map{ |x| x[:name] }.sort.uniq
result.push(enum_type :LegalKindString, HASH[:nodes].map{ |x| x[:name] }.sort.uniq)
result.push(enum_type :LegalSequenceKind, CeleryScriptSettingsBag::STEPS.sort)
result.push(enum_type :DataChangeType, CeleryScriptSettingsBag::ALLOWED_CHAGES)
result.push(enum_type :ResourceName, CeleryScriptSettingsBag::RESOURCE_NAME)
result.push(enum_type :PointType, CeleryScriptSettingsBag::ALLOWED_POINTER_TYPE)
puts result.join.gsub("\n\n\n", "\n")

View file

@ -38,7 +38,6 @@
"@types/lodash": "^4.14.78",
"@types/markdown-it": "^0.0.4",
"@types/moxios": "^0.4.5",
"@types/mqtt": "^2.5.0",
"@types/node": "^8.0.46",
"@types/react": "15.0.39",
"@types/react-color": "^2.13.2",
@ -53,7 +52,7 @@
"enzyme": "^3.1.0",
"enzyme-adapter-react-15": "^1.0.2",
"extract-text-webpack-plugin": "^3.0.1",
"farmbot": "5.0.1",
"farmbot": "https://github.com/RickCarlino/farmbot-js.git",
"farmbot-toastr": "^1.0.3",
"fastclick": "^1.0.6",
"file-loader": "^1.1.5",

View file

@ -63,10 +63,12 @@ export function editStep({ step, sequence, index, executor }: EditStepProps) {
}
/** Initialize (but don't save) an indexed / tagged resource. */
export function init(resource: TaggedResource): ReduxAction<TaggedResource> {
resource.body.id = 0;
resource.specialStatus = SpecialStatus.DIRTY;
/** Technically, this happens in the reducer, but I like to be extra safe. */
export function init(resource: TaggedResource,
/** Set to "true" when you want an `undefined` SpecialStatus. */
clean = false): ReduxAction<TaggedResource> {
resource.body.id = resource.body.id || 0;
resource.specialStatus = clean ? undefined : SpecialStatus.DIRTY;
/** Don't touch this- very important! */
resource.uuid = generateUuid(resource.body.id, resource.kind);
return { type: Actions.INIT_RESOURCE, payload: resource };
}

View file

@ -19,6 +19,7 @@ import { init } from "../api/crud";
import { versionOK } from "../devices/reducer";
import { AuthState } from "../auth/interfaces";
import { TaggedResource } from "../resources/tagged_resources";
import { TempDebug } from "./temp_debug";
export const TITLE = "New message from bot";
@ -65,7 +66,7 @@ export const initLog = (log: Log): ReduxAction<TaggedResource> => init({
specialStatus: undefined,
uuid: "MUST_CHANGE",
body: log
});
}, true);
export const bothUp = () => {
dispatchNetworkUp("user.mqtt");
@ -143,10 +144,11 @@ const attachEventListeners =
bot.on("online", onOnline);
bot.on("offline", onOffline);
bot.on("sent", onSent(bot.client));
bot.on("Log", onLogs(dispatch));
bot.on("logs", onLogs(dispatch));
bot.on("status", onStatus(dispatch, getState));
bot.on("malformed", onMalformed);
readStatus().then(changeLastClientConnected(bot), noop);
bot.client.on("message", TempDebug(dispatch, getState));
};
/** Connect to MQTT and attach all relevant event handlers. */

View file

@ -1,6 +1,10 @@
import { store } from "../redux/store";
import { networkUp, networkDown } from "./actions";
import { Edge } from "./interfaces";
import { debounce } from "lodash";
/** Debounce calls to these functions to avoid unnecessary re-paints. */
const SLOWDOWN_TIME = 1500;
/* ABOUT THIS FILE: These functions allow us to mark the network as up or
down from anywhere within the app (even outside of React-Redux). I usually avoid
@ -8,11 +12,11 @@ directly importing `store`, but in this particular instance it might be
unavoidable. */
export let dispatchNetworkUp =
(edge: Edge, at = (new Date()).toJSON()) => {
debounce((edge: Edge, at = (new Date()).toJSON()) => {
return store.dispatch(networkUp(edge, at));
};
}, SLOWDOWN_TIME);
export let dispatchNetworkDown =
(edge: Edge, at = (new Date()).toJSON()) => {
debounce((edge: Edge, at = (new Date()).toJSON()) => {
return store.dispatch(networkDown(edge, at));
};
}, SLOWDOWN_TIME);

View file

@ -0,0 +1,140 @@
import { GetState } from "../redux/interfaces";
import { maybeDetermineUuid } from "../resources/selectors";
import { ResourceName, TaggedResource } from "../resources/tagged_resources";
import { destroyOK } from "../resources/actions";
import { overwrite, init } from "../api/crud";
import { fancyDebug } from "../util";
interface UpdateMqttData {
status: "UPDATE"
kind: ResourceName;
id: number;
body: object;
sessionId: string;
}
interface DeleteMqttData {
status: "DELETE"
kind: ResourceName;
id: number;
}
interface BadMqttData {
status: "ERR";
reason: string;
}
interface SkipMqttData {
status: "SKIP";
}
type MqttDataResult =
| UpdateMqttData
| DeleteMqttData
| SkipMqttData
| BadMqttData;
enum Reason {
BAD_KIND = "missing `kind`",
BAD_ID = "No ID or invalid ID.",
BAD_CHAN = "Expected exactly 5 segments in channel"
}
interface SyncPayload {
args: { label: string; };
body: object | undefined;
}
function decodeBinary(payload: Buffer): SyncPayload {
return JSON.parse((payload).toString());
}
function routeMqttData(chan: string, payload: Buffer): MqttDataResult {
/** Skip irrelevant messages */
if (!chan.includes("sync")) { return { status: "SKIP" }; }
/** Extract, Validate and scrub the data as it enters the frontend. */
const parts = chan.split("/");
if (parts.length !== 5) { return { status: "ERR", reason: Reason.BAD_CHAN }; }
const id = parseInt(parts.pop() || "0", 10);
const kind = parts.pop() as ResourceName | undefined;
const { body, args } = decodeBinary(payload);
if (!kind) { return { status: "ERR", reason: Reason.BAD_KIND }; }
if (!id) { return { status: "ERR", reason: Reason.BAD_ID }; }
if (body) {
return { status: "UPDATE", body, kind: kind, id, sessionId: args.label };
} else {
return { status: "DELETE", kind: kind, id }; // 'null' body means delete.
}
}
const asTaggedResource = (data: UpdateMqttData, uuid: string): TaggedResource => {
return {
// tslint:disable-next-line:no-any
kind: (data.kind as any),
uuid,
specialStatus: undefined,
// tslint:disable-next-line:no-any
body: (data.body as any) // I trust you, API...
};
};
const handleCreate =
(data: UpdateMqttData) => init(asTaggedResource(data, "IS SET LATER"), true);
const handleUpdate =
(d: UpdateMqttData, uid: string) => {
const tr = asTaggedResource(d, uid);
return overwrite(tr, tr.body);
};
const handleErr = (d: BadMqttData) => console.log("DATA VALIDATION ERROR!", d);
const handleSkip = () => console.log("SKIP");
export const TempDebug =
(dispatch: Function, getState: GetState) =>
(chan: string, payload: Buffer) => {
const data = routeMqttData(chan, payload);
switch (data.status) {
case "ERR": return handleErr(data);
case "SKIP": return handleSkip();
case "DELETE":
const { id, kind } = data;
const i = getState().resources.index;
const r = i.references[maybeDetermineUuid(i, kind, id) || "NO"];
if (r) {
return dispatch(destroyOK(r));
} else {
return;
}
case "UPDATE":
whoah(dispatch, getState, data);
}
};
// Here be dragons.
// The ultimate problem: We need to know if the incoming data update was created
// by us or some other user. That information lets us know if we are UPDATEing
// data or INSERTing data.
function whoah(dispatch: Function,
getState: GetState,
data: UpdateMqttData,
backoff = 200) {
const state = getState();
const { index } = state.resources;
const jti: string =
(state.auth && (state.auth.token.unencoded as any)["jti"]) || "";
fancyDebug({ jti, ...data });
const uuid = maybeDetermineUuid(index, data.kind, data.id);
if (uuid) {
return dispatch(handleUpdate(data, uuid));
} else {
if (data.sessionId !== jti) { // Ignores local echo.
dispatch(handleCreate(data));
}
}
}

View file

@ -372,7 +372,7 @@ export enum Actions {
// Resources
DESTROY_RESOURCE_OK = "DESTROY_RESOURCE_OK",
INIT_RESOURCE = "INIT_RESOURCE",
SAVE_SPECIAL_RESOURCE = "SAVE_SPECIAL_RESOURCE",
SAVE_OPENFARM_RESOURCE = "SAVE_OPENFARM_RESOURCE",
SAVE_RESOURCE_OK = "SAVE_RESOURCE_OK",
UPDATE_RESOURCE_OK = "UPDATE_RESOURCE_OK",
EDIT_RESOURCE = "EDIT_RESOURCE",

View file

@ -1,73 +0,0 @@
import { DataChangeType, Dictionary } from "farmbot/dist";
import { getDevice } from "./device";
import { box } from "boxed_value";
import * as _ from "lodash";
import { ResourceName } from "./resources/tagged_resources";
export let METHOD_MAP: Dictionary<DataChangeType> = {
"post": "add",
"put": "update",
"patch": "update",
"delete": "remove"
};
export let METHODS = ["post", "put", "patch", "delete"];
export let RESOURCES: ResourceName[] = [
"Point",
"Regimen",
"Peripheral",
"Log",
"Sequence",
"FarmEvent",
"Point",
"Device"
];
// PROBLEM: The bot doesn't know if the user has changed any of the data.
// GOOD SOLUTION: Create a push notification system on the API.
// FAST SOLUTION: Ping the bot every time we push "save" or "update".
// Our hope is to eventually move this logic into the API.
export function notifyBotOfChanges(url: string | undefined, action: DataChangeType) {
if (url) {
url.split("/").filter((chunk: ResourceName) => {
return RESOURCES.includes(chunk);
}).map(async function (resource: ResourceName) {
getDevice().dataUpdate(action, { [resource]: inferUpdateId(url) });
});
}
}
/** More nasty hacks until we have time to implement proper API push state
* notifications. */
function inferUpdateId(url: string) {
try {
let ids = url
.split("/")
.filter(x => !x.includes(",")) // Don't allow batch endpoints to participate.
.map(x => parseInt(x, 10))
.filter(x => !_.isNaN(x));
let id: number | undefined = ids[0];
let isNum = _.isNumber(id);
let onlyOne = ids.length === 1;
return (isNum && onlyOne) ? ("" + id) : "*";
} catch (error) { // Don't crash - just keep moving along. This is a temp patch.
return "*";
}
}
/** The input of an axios error interceptor is an "any" type.
* Sometimes it will be a real Axios error, other times it will not be.
*/
export interface SafeError {
response: {
status: number;
};
}
/** Prevents hard-to-find NPEs and type errors inside of interceptors. */
export function isSafeError(x: SafeError | any): x is SafeError {
return !!(
(box(x).kind === "object") &&
(box(x.response).kind === "object") &&
(box(x.response.status).kind === "number"));
}

View file

@ -1,25 +1,34 @@
import { t } from "i18next";
import { error } from "farmbot-toastr";
import {
METHODS,
notifyBotOfChanges,
METHOD_MAP,
SafeError,
isSafeError
} from "./interceptor_support";
import { API } from "./api/index";
import { AuthState } from "./auth/interfaces";
import * as _ from "lodash";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import { Content } from "./constants";
import { dispatchNetworkUp, dispatchNetworkDown } from "./connectivity/index";
import { box } from "boxed_value";
import { UnsafeError } from "./interfaces";
import { store } from "./redux/store";
/** The input of an axios error interceptor is an "any" type.
* Sometimes it will be a real Axios error, other times it will not be.
*/
export interface SafeError {
response: {
status: number;
};
}
/** Prevents hard-to-find NPEs and type errors inside of interceptors. */
export function isSafeError(x: SafeError | UnsafeError): x is SafeError {
return !!(
(box(x).kind === "object") &&
(box(x.response).kind === "object") &&
(box(x.response.status).kind === "number"));
}
export function responseFulfilled(input: AxiosResponse): AxiosResponse {
const method = input.config.method;
dispatchNetworkUp("user.api");
if (method && METHODS.includes(method)) {
notifyBotOfChanges(input.config.url, METHOD_MAP[method]);
}
return input;
}
@ -64,9 +73,9 @@ export function requestFulfilled(auth: AuthState) {
const isAPIRequest = req.includes(API.current.baseUrl);
if (isAPIRequest) {
config.headers = config.headers || {};
const headers = (config.headers as
{ Authorization: string | undefined });
headers.Authorization = auth.token.encoded || "CANT_FIND_TOKEN";
const headers: { Authorization: string | undefined } = (config.headers);
const authn = store.getState().auth;
headers.Authorization = authn ? authn.token.encoded : "CANT_FIND_TOKEN";
}
return config;
};

View file

@ -87,7 +87,7 @@ const afterEach = (state: RestResources, a: ReduxAction<object>) => {
/** Responsible for all RESTful resources. */
export let resourceReducer = generateReducer
<RestResources>(initialState, afterEach)
.add<ResourceReadyPayl>(Actions.SAVE_SPECIAL_RESOURCE, (s, { payload }) => {
.add<ResourceReadyPayl>(Actions.SAVE_OPENFARM_RESOURCE, (s, { payload }) => {
const data = arrayWrap(payload);
const kind = payload.name;
data.map((body: ResourceReadyPayl) => {
@ -192,17 +192,10 @@ export let resourceReducer = generateReducer
dontTouchThis(original);
return s;
})
.add<TaggedResource>(Actions.INIT_RESOURCE, (s, { payload }) => {
.add<TaggedResource>(Actions.INIT_RESOURCE, (s: RestResources, { payload }) => {
const tr = payload;
const uuid = tr.uuid;
reindexResource(s.index, tr);
if (tr.kind === "Log") {
// Since logs don't come from the API all the time, they are the only
// resource (right now) that can have an id of `undefined` and not dirty.
findByUuid(s.index, uuid).specialStatus = undefined;
} else {
findByUuid(s.index, uuid).specialStatus = SpecialStatus.DIRTY;
}
s.index.references[tr.uuid] = tr;
sanityCheck(tr);
dontTouchThis(tr);
return s;
@ -288,7 +281,9 @@ export function joinKindAndId(kind: ResourceName, id: number | undefined) {
return `${kind}.${id || 0}`;
}
const filterOutUuid = (tr: TaggedResource) => (uuid: string) => uuid !== tr.uuid;
const filterOutUuid =
(tr: TaggedResource) => (uuid: string) => uuid !== tr.uuid;
function removeFromIndex(index: ResourceIndex, tr: TaggedResource) {
const { kind } = tr;
const id = tr.body.id;

View file

@ -31,10 +31,22 @@ import {
import { CowardlyDictionary, betterCompact, sortResourcesById } from "../util";
type StringMap = CowardlyDictionary<string>;
export let findId = (index: ResourceIndex, kind: ResourceName, id: number) => {
/** Similar to findId(), but does not throw exceptions. Do NOT use this method
* unless there is actually a reason for the resource to not have a UUID.
* `findId()` is more appropriate 99% of the time because it can spot
* referential integrity issues. */
export let maybeDetermineUuid =
(index: ResourceIndex, kind: ResourceName, id: number) => {
const kni = joinKindAndId(kind, id);
const uuid = index.byKindAndId[kni];
if (uuid) {
assertUuid(kind, uuid);
return uuid;
}
};
const uuid = index.byKindAndId[joinKindAndId(kind, id)];
assertUuid(kind, uuid);
export let findId = (index: ResourceIndex, kind: ResourceName, id: number) => {
const uuid = maybeDetermineUuid(index, kind, id);
if (uuid) {
return uuid;
} else {

View file

@ -5,7 +5,8 @@ import { TaggedResource } from "./tagged_resources";
/** HISTORICAL NOTES:
* This file is the result of some very subtle bugs relating to dynamic
* children in React components on the sequence editor page. Simply put, the
* sequence editor needs a way to uniquely identify each step when rendering.
* sequence editor needs a way to uniquely identify each sequence step when
* rendering a sequence.
*
* PROBLEM:
* - React needs a unique `key` prop when iterating over UI list elements.

View file

@ -204,7 +204,7 @@ export class RootComponent extends React.Component<RootComponentProps, {}> {
},
},
{
path: "FarmEvent",
path: "farm_events",
getComponent(_discard: void, cb: Function) {
import("./farm_designer/farm_events/farm_events")
.then(module => cb(undefined, module.FarmEvents))

View file

@ -109,12 +109,6 @@
dependencies:
axios "^0.16.1"
"@types/mqtt@^2.5.0":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@types/mqtt/-/mqtt-2.5.0.tgz#bc54c2d53f509282168da4a9af865de95bee5101"
dependencies:
mqtt "*"
"@types/node@^6.0.46":
version "6.0.90"
resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.90.tgz#0ed74833fa1b73dcdb9409dcb1c97ec0a8b13b02"
@ -2009,6 +2003,13 @@ farmbot@5.0.1:
mqtt "^2.13.1"
typescript "^2.4.2"
"farmbot@https://github.com/RickCarlino/farmbot-js.git":
version "5.0.1"
resolved "https://github.com/RickCarlino/farmbot-js.git#d27f640d15cada92fa5d2cfa28957c0fe2530a3d"
dependencies:
mqtt "^2.13.1"
typescript "^2.4.2"
fast-deep-equal@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-0.1.0.tgz#5c6f4599aba6b333ee3342e2ed978672f1001f8d"
@ -3799,7 +3800,7 @@ mqtt-packet@^5.4.0:
process-nextick-args "^1.0.7"
safe-buffer "^5.1.0"
mqtt@*, mqtt@^2.13.1:
mqtt@^2.13.1:
version "2.13.1"
resolved "https://registry.yarnpkg.com/mqtt/-/mqtt-2.13.1.tgz#d83c7c5d9dc37864a363453f61fb5e1523c0144a"
dependencies: