diff --git a/frontend/src/metabase/admin/routes.jsx b/frontend/src/metabase/admin/routes.jsx index 503f0c1df37a88c8ac980511264eb96c94ddfbd8..e0b7a79d872e219174b109c94fb87d3a2f68ae19 100644 --- a/frontend/src/metabase/admin/routes.jsx +++ b/frontend/src/metabase/admin/routes.jsx @@ -4,6 +4,7 @@ import { IndexRoute, IndexRedirect } from "react-router"; import { t } from "c-3po"; import { withBackground } from "metabase/hoc/Background"; +import { ModalRoute } from "metabase/hoc/ModalRoute"; // Settings import SettingsEditorApp from "metabase/admin/settings/containers/SettingsEditorApp.jsx"; @@ -21,6 +22,9 @@ import AdminPeopleApp from "metabase/admin/people/containers/AdminPeopleApp.jsx" import FieldApp from "metabase/admin/datamodel/containers/FieldApp.jsx"; import TableSettingsApp from "metabase/admin/datamodel/containers/TableSettingsApp.jsx"; +import TasksApp from "metabase/admin/tasks/containers/TasksApp"; +import TaskModal from "metabase/admin/tasks/containers/TaskModal"; + // People import PeopleListingApp from "metabase/admin/people/containers/PeopleListingApp.jsx"; import GroupsListingApp from "metabase/admin/people/containers/GroupsListingApp.jsx"; @@ -75,6 +79,14 @@ const getRoutes = (store, IsAdmin) => ( </Route> </Route> + {/* Troubleshooting */} + <Route path="troubleshooting" title={t`Troubleshooting`}> + <IndexRedirect to="tasks" /> + <Route path="tasks" component={TasksApp}> + <ModalRoute path=":taskId" modal={TaskModal} /> + </Route> + </Route> + {/* SETTINGS */} <Route path="settings" title={t`Settings`}> <IndexRedirect to="/admin/settings/setup" /> diff --git a/frontend/src/metabase/admin/tasks/containers/TaskModal.jsx b/frontend/src/metabase/admin/tasks/containers/TaskModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cdc206e7c37ec65589dbeb7561b8f78c2c9c8d10 --- /dev/null +++ b/frontend/src/metabase/admin/tasks/containers/TaskModal.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import { t } from "c-3po"; +import { connect } from "react-redux"; +import { goBack } from "react-router-redux"; + +import { entityObjectLoader } from "metabase/entities/containers/EntityObjectLoader"; + +import Code from "metabase/components/Code"; +import ModalContent from "metabase/components/ModalContent"; + +@entityObjectLoader({ + entityType: "tasks", + entityId: (state, props) => props.params.taskId, +}) +@connect(null, { goBack }) +class TaskModal extends React.Component { + render() { + const { object } = this.props; + return ( + <ModalContent title={t`Task details`} onClose={() => this.props.goBack()}> + <Code>{JSON.stringify(object.task_details)}</Code> + </ModalContent> + ); + } +} + +export default TaskModal; diff --git a/frontend/src/metabase/admin/tasks/containers/TasksApp.jsx b/frontend/src/metabase/admin/tasks/containers/TasksApp.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c554ca324a7b0298c3377d364fcf380fea54710f --- /dev/null +++ b/frontend/src/metabase/admin/tasks/containers/TasksApp.jsx @@ -0,0 +1,97 @@ +import React from "react"; +import { t } from "c-3po"; +import { Box, Flex } from "grid-styled"; + +import { entityListLoader } from "metabase/entities/containers/EntityListLoader"; + +import AdminHeader from "metabase/components/AdminHeader"; +import Icon, { IconWrapper } from "metabase/components/Icon"; +import Link from "metabase/components/Link"; +import Tooltip from "metabase/components/Tooltip"; + +@entityListLoader({ + entityType: "tasks", + pageSize: 50, +}) +class TasksApp extends React.Component { + constructor(props) { + super(props); + this.state = { + offset: this.props.entityQuery.offset, + }; + } + render() { + const { + tasks, + page, + pageSize, + onNextPage, + onPreviousPage, + children, + } = this.props; + return ( + <Box p={3}> + <Flex align="center"> + <Flex align="center"> + <AdminHeader title={t`Troubleshooting logs`} /> + <Tooltip + tooltip={t`Trying to get to the bottom of something? This section shows logs of Metabase's background tasks, which can help shed light on what's going on.`} + > + <Icon + name="info" + ml={1} + style={{ marginTop: 5 }} + className="text-brand-hover cursor-pointer text-medium" + /> + </Tooltip> + </Flex> + <Flex align="center" ml="auto"> + <span className="text-bold mr1"> + {page * pageSize + 1} - {page * pageSize + tasks.length} + </span> + <IconWrapper onClick={onPreviousPage} disabled={!onPreviousPage}> + <Icon name="chevronleft" /> + </IconWrapper> + <IconWrapper small onClick={onNextPage} disabled={!onNextPage}> + <Icon name="chevronright" /> + </IconWrapper> + </Flex> + </Flex> + + <table className="ContentTable mt2"> + <thead> + <th>{t`Task`}</th> + <th>{t`DB ID`}</th> + <th>{t`Started at`}</th> + <th>{t`Ended at`}</th> + <th>{t`Duration (ms)`}</th> + <th>{t`Details`}</th> + </thead> + <tbody> + {tasks.map(task => ( + <tr key={task.id}> + <td className="text-bold">{task.task}</td> + <td>{task.db_id}</td> + <td>{task.started_at}</td> + <td>{task.ended_at}</td> + <td>{task.duration}</td> + <td> + <Link + className="link text-bold" + to={`/admin/troubleshooting/tasks/${task.id}`} + >{t`View`}</Link> + </td> + </tr> + ))} + </tbody> + </table> + { + // render 'children' so that the invididual task modals show up + children + } + </Box> + ); + } +} + +export default TasksApp; diff --git a/frontend/src/metabase/containers/CollectionListLoader.jsx b/frontend/src/metabase/containers/CollectionListLoader.jsx index c39083defec6df697d5d048a039cc0d56101694d..5fded6930df18bc19c8aa3932de59a74502a1d96 100644 --- a/frontend/src/metabase/containers/CollectionListLoader.jsx +++ b/frontend/src/metabase/containers/CollectionListLoader.jsx @@ -11,7 +11,6 @@ const CollectionListLoader = ({ children, writable, ...props }: Props) => ( <EntityListLoader entityType="collections" {...props} - // $FlowFixMe: flow doesn't know about dyanmically generated "collections" prop children={({ list, collections, ...props }) => children({ list: writable ? list && list.filter(c => c.can_write) : list, diff --git a/frontend/src/metabase/css/core/text.css b/frontend/src/metabase/css/core/text.css index d4fc21679781f7dd3c8eaf80d5f0acc9ec9e6934..4087dd67b4c8f66c42bfc17f523d86d1d0c9eee9 100644 --- a/frontend/src/metabase/css/core/text.css +++ b/frontend/src/metabase/css/core/text.css @@ -175,7 +175,7 @@ border-radius: 2px; padding: 0.2em 0.4em; line-height: 1.4em; - white-space: pre; + white-space: pre-wrap; } .text-monospace, diff --git a/frontend/src/metabase/entities/containers/EntityListLoader.jsx b/frontend/src/metabase/entities/containers/EntityListLoader.jsx index 31987dd1e3f31365ad4d1cd3fd74f1d069062f4b..dc6e322daecd4ea705aab508d8b51f09c1128304 100644 --- a/frontend/src/metabase/entities/containers/EntityListLoader.jsx +++ b/frontend/src/metabase/entities/containers/EntityListLoader.jsx @@ -7,6 +7,7 @@ import { createSelector } from "reselect"; import { createMemoizedSelector } from "metabase/lib/redux"; import entityType from "./EntityType"; +import paginationState from "metabase/hoc/PaginationState"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; export type Props = { @@ -41,9 +42,13 @@ const getMemoizedEntityQuery = createMemoizedSelector( ); @entityType() +@paginationState() @connect((state, props) => { - const { entityDef } = props; - const entityQuery = getMemoizedEntityQuery(state, props); + let { entityDef, entityQuery, page, pageSize } = props; + if (typeof pageSize === "number" && typeof page === "number") { + entityQuery = { limit: pageSize, offset: pageSize * page, ...entityQuery }; + } + entityQuery = getMemoizedEntityQuery(state, { entityQuery }); return { entityQuery, list: entityDef.selectors.getList(state, { entityQuery }), @@ -74,22 +79,33 @@ export default class EntityListLoader extends React.Component { ); } + async fetchList( + // $FlowFixMe: fetchList provided by @connect + { fetchList, entityQuery, pageSize, onChangeHasMorePages }, + options?: any, + ) { + const result = await fetchList(entityQuery, options); + if (typeof pageSize === "number" && onChangeHasMorePages) { + onChangeHasMorePages( + !result.payload.result || result.payload.result.length === pageSize, + ); + } + return result; + } + componentWillMount() { - // $FlowFixMe: provided by @connect - this.props.fetchList(this.props.entityQuery, { reload: this.props.reload }); + this.fetchList(this.props, { reload: this.props.reload }); } componentWillReceiveProps(nextProps: Props) { if (!_.isEqual(nextProps.entityQuery, this.props.entityQuery)) { // entityQuery changed, reload - // $FlowFixMe: provided by @connect - nextProps.fetchList(nextProps.entityQuery, { reload: nextProps.reload }); + this.fetchList(nextProps, { reload: nextProps.reload }); } else if (this.props.loaded && !nextProps.loaded && !nextProps.loading) { // transitioned from loaded to not loaded, and isn't yet loading again // this typically means the list request state was cleared by a // create/update/delete action - // $FlowFixMe: provided by @connect - nextProps.fetchList(nextProps.entityQuery); + this.fetchList(nextProps); } } @@ -127,8 +143,7 @@ export default class EntityListLoader extends React.Component { } reload = () => { - // $FlowFixMe: provided by @connect - return this.props.fetchList(this.props.entityQuery, { reload: true }); + this.fetchList(this.props, { reload: true }); }; } diff --git a/frontend/src/metabase/entities/index.js b/frontend/src/metabase/entities/index.js index 2f67c01b8ea1e6833270f823cffa6ea158da04d6..ccf936380844b812cbbe9ce3cb0f66be9c95fcc3 100644 --- a/frontend/src/metabase/entities/index.js +++ b/frontend/src/metabase/entities/index.js @@ -9,6 +9,7 @@ export tables from "./tables"; export fields from "./fields"; export metrics from "./metrics"; export segments from "./segments"; +export tasks from "./tasks"; export users from "./users"; diff --git a/frontend/src/metabase/entities/tasks.js b/frontend/src/metabase/entities/tasks.js new file mode 100644 index 0000000000000000000000000000000000000000..b7684054577e5ded8e0774bba763b0f59ea80146 --- /dev/null +++ b/frontend/src/metabase/entities/tasks.js @@ -0,0 +1,6 @@ +import { createEntity } from "metabase/lib/entities"; + +export default createEntity({ + name: "tasks", + path: "/api/task", +}); diff --git a/frontend/src/metabase/hoc/PaginationState.jsx b/frontend/src/metabase/hoc/PaginationState.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e85ff90b32f985f52eb1a6f964a515b06aa06c40 --- /dev/null +++ b/frontend/src/metabase/hoc/PaginationState.jsx @@ -0,0 +1,37 @@ +import React from "react"; + +const paginationState = () => ComposedComponent => + class extends React.Component { + constructor(props) { + super(props); + this.state = { + page: props.initialPage || 0, + hasMorePages: null, + }; + } + handleChangeHasMorePages = hasMorePages => { + this.setState({ hasMorePages }); + }; + handleNextPage = () => { + this.setState({ page: this.state.page + 1, hasMorePages: null }); + }; + handlePreviousPage = () => { + this.setState({ page: this.state.page - 1, hasMorePages: true }); + }; + render() { + const isPaginated = typeof this.props.pageSize === "number"; + const extraProps = isPaginated + ? { + ...this.state, + onChangeHasMorePages: this.handleChangeHasMorePages, + onNextPage: this.state.hasMorePages ? this.handleNextPage : null, + onPreviousPage: + this.state.page > 0 ? this.handlePreviousPage : null, + } + : {}; + + return <ComposedComponent {...extraProps} {...this.props} />; + } + }; + +export default paginationState; diff --git a/frontend/src/metabase/lib/entities.js b/frontend/src/metabase/lib/entities.js index c282cf436ea2ca8330323c47aa2fec0b4d147530..c4fc49bcc0cb1a4a43687f61139a577d8c60457a 100644 --- a/frontend/src/metabase/lib/entities.js +++ b/frontend/src/metabase/lib/entities.js @@ -344,11 +344,27 @@ export function createEntity(def: EntityDefinition): Entity { requestStatePath: getListStatePath(entityQuery), existingStatePath: getListStatePath(entityQuery), getData: async () => { - const { result, entities } = normalize( - await entity.api.list(entityQuery || {}), - [entity.schema], - ); - return { result, entities, entityQuery }; + const fetched = await entity.api.list(entityQuery || {}); + let results = fetched; + + // for now at least paginated endpoints have a 'data' property that + // contains the actual entries, if that is on the response we should + // use that as the 'results' + if (fetched.data) { + results = fetched.data; + } + const { result, entities } = normalize(results, [entity.schema]); + return { + result, + entities, + entityQuery, + // capture some extra details from the result just in case? + resultDetails: { + total: fetched.total, + offset: fetched.offset, + limit: fetched.limit, + }, + }; }, }), ), diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 85c75f935cadbd2f3ffbe8faa54302ecd995174f..fa618569dabe5ee28866939728b53a50231b6f44 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -221,6 +221,11 @@ export default class Navbar extends Component { path="/admin/permissions" currentPath={this.props.path} /> + <AdminNavItem + name={t`Troubleshooting`} + path="/admin/troubleshooting" + currentPath={this.props.path} + /> </ul> <ProfileLink {...this.props} /> diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index b614da645883e20150fda06ed5b1b121f69475c2..ddd8701ce4268b85348dca99570a69ca733f1012 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -312,6 +312,10 @@ export const I18NApi = { locale: GET("/app/locales/:locale.json"), }; +export const TaskApi = { + get: GET("api/task"), +}; + export function setPublicQuestionEndpoints(uuid: string) { setFieldEndpoints("/api/public/card/:uuid", { uuid }); } diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj index e5babb2903699e7fe24ab57aa422d204f78694fe..616d55cce3edaebd33f7493b1dd2ce317c32b4b1 100644 --- a/src/metabase/api/routes.clj +++ b/src/metabase/api/routes.clj @@ -31,6 +31,7 @@ [slack :as slack] [table :as table] [tiles :as tiles] + [task :as task] [user :as user] [util :as util]] [metabase.middleware :as middleware] @@ -82,6 +83,7 @@ (context "/setup" [] setup/routes) (context "/slack" [] (+auth slack/routes)) (context "/table" [] (+auth table/routes)) + (context "/task" [] (+auth task/routes)) (context "/tiles" [] (+auth tiles/routes)) (context "/user" [] (+auth user/routes)) (context "/util" [] util/routes) diff --git a/src/metabase/api/task.clj b/src/metabase/api/task.clj new file mode 100644 index 0000000000000000000000000000000000000000..4f40cf95e31a5b7fa5c62ac490698d2d95fb62a9 --- /dev/null +++ b/src/metabase/api/task.clj @@ -0,0 +1,44 @@ +(ns metabase.api.task + "/api/task endpoints" + (:require [compojure.core :refer [GET]] + [metabase.api.common :as api] + [metabase.models.task-history :as task-history :refer [TaskHistory]] + [metabase.util + [i18n :as ui18n :refer [tru]] + [schema :as su]] + [schema.core :as s] + [toucan.db :as db])) + +(defn- check-valid-limit [limit offset] + (when (and offset (not limit)) + (throw + (ui18n/ex-info (tru "When including an offset, a limit must also be included.") + {:status-code 400})))) + +(defn- check-valid-offset [limit offset] + (when (and limit (not offset)) + (throw + (ui18n/ex-info (tru "When including a limit, an offset must also be included.") + {:status-code 400})))) + +(api/defendpoint GET "/" + "Fetch a list of recent tasks stored as Task History" + [limit offset] + {limit (s/maybe su/IntStringGreaterThanZero) + offset (s/maybe su/IntStringGreaterThanOrEqualToZero)} + (api/check-superuser) + (check-valid-limit limit offset) + (check-valid-offset limit offset) + (let [limit-int (some-> limit Integer/parseInt) + offset-int (some-> offset Integer/parseInt)] + {:total (db/count TaskHistory) + :limit limit-int + :offset offset-int + :data (task-history/all limit-int offset-int)})) + +(api/defendpoint GET "/:id" + "Get `TaskHistory` entry with ID." + [id] + (api/read-check TaskHistory id)) + +(api/define-routes) diff --git a/src/metabase/models/task_history.clj b/src/metabase/models/task_history.clj index a2257b13445f303895dd9b09ae80a596b6449a12..0b699dc4199e2b61a0813539334d491b0363c3a6 100644 --- a/src/metabase/models/task_history.clj +++ b/src/metabase/models/task_history.clj @@ -1,6 +1,8 @@ (ns metabase.models.task-history (:require [metabase.models.interface :as i] [metabase.util :as u] + [metabase.util.schema :as su] + [schema.core :as s] [toucan [db :as db] [models :as models]])) @@ -29,3 +31,13 @@ (merge i/IObjectPermissionsDefaults {:can-read? i/superuser? :can-write? i/superuser?})) + +(s/defn all + "Return all TaskHistory entries, applying `limit` and `offset` if not nil" + [limit :- (s/maybe su/IntGreaterThanZero) + offset :- (s/maybe su/IntGreaterThanOrEqualToZero)] + (db/select TaskHistory (merge {:order-by [[:ended_at :desc]]} + (when limit + {:limit limit}) + (when offset + {:offset offset})))) diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj index b6f46b63be121122d72749bfbb19ffb54d723e9e..96c17e45100194cf1d59a1d87f0f54e59d713524 100644 --- a/src/metabase/util/schema.clj +++ b/src/metabase/util/schema.clj @@ -203,6 +203,12 @@ (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (< 0 (Integer/parseInt %)))) (tru "value must be a valid integer greater than zero."))) +(def IntStringGreaterThanOrEqualToZero + "Schema for a string that can be parsed as an integer, and is greater than or equal to zero. + Something that adheres to this schema is guaranteed to to work with `Integer/parseInt`." + (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (<= 0 (Integer/parseInt %)))) + (tru "value must be a valid integer greater than or equal to zero."))) + (defn- boolean-string? ^Boolean [s] (boolean (when (string? s) (let [s (str/lower-case s)] diff --git a/test/metabase/api/task_test.clj b/test/metabase/api/task_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..2b9aed04ccefd3ce1b54c62ca99f08f0babec9e9 --- /dev/null +++ b/test/metabase/api/task_test.clj @@ -0,0 +1,119 @@ +(ns metabase.api.task-test + (:require [clj-time.core :as time] + [expectations :refer :all] + [metabase.models.task-history :refer [TaskHistory]] + [metabase.test.data.users :as users] + [metabase.test.util :as tu] + [metabase.util :as u] + [metabase.util.date :as du] + [toucan.db :as db] + [toucan.util.test :as tt])) + +(def ^:private default-task-history + {:id true, :db_id true, :started_at true, :ended_at true, :duration 10, :task_details nil}) + +(defn- generate-tasks + "Creates `n` task history maps with guaranteed increasing `:ended_at` times. This means that when stored and queried + via the GET `/` endpoint, will return in reverse order from how this function returns the task history maps." + [n] + (let [task-names (repeatedly n tu/random-name) + now (time/now)] + (map-indexed (fn [idx task-name] + {:task task-name + :started_at (du/->Timestamp now) + :ended_at (du/->Timestamp (time/plus now (time/seconds idx)))}) + task-names))) + +;; Only superusers can query for TaskHistory +(expect + "You don't have permissions to do that." + ((users/user->client :rasta) :get 403 "task/")) + +;; Superusers can query TaskHistory, should return DB results +(let [[task-hist-1 task-hist-2] (generate-tasks 2) + task-hist-1 (assoc task-hist-1 :duration 100) + task-hist-2 (assoc task-hist-1 :duration 200 :task_details {:some "complex", :data "here"}) + task-names (set (map :task [task-hist-1 task-hist-2]))] + (expect + (set (map (fn [task-hist] + (merge default-task-history (select-keys task-hist [:task :duration :task_details]))) + [task-hist-2 task-hist-1])) + (tt/with-temp* [TaskHistory [task-1 task-hist-1] + TaskHistory [task-2 task-hist-2]] + (set (for [result (:data ((users/user->client :crowberto) :get 200 "task/")) + :when (contains? task-names (:task result))] + (tu/boolean-ids-and-timestamps result)))))) + +;; Multiple results should be sorted via `:ended_at`. Below creates two tasks, the second one has a later `:ended_at` +;; date and should be returned first +(let [[task-hist-1 task-hist-2 :as task-histories] (generate-tasks 2) + task-names (set (map :task task-histories))] + (expect + (map (fn [{:keys [task]}] + (assoc default-task-history :task task)) + [task-hist-2 task-hist-1]) + (tt/with-temp* [TaskHistory [task-1 task-hist-1] + TaskHistory [task-2 task-hist-2]] + (for [result (:data ((users/user->client :crowberto) :get 200 "task/")) + :when (contains? task-names (:task result))] + (tu/boolean-ids-and-timestamps result))))) + +;; Should fail when only including a limit +(expect + "When including a limit, an offset must also be included." + ((users/user->client :crowberto) :get 400 "task/" :limit 100)) + +;; Should fail when only including an offset +(expect + "When including an offset, a limit must also be included." + ((users/user->client :crowberto) :get 400 "task/" :offset 100)) + +;; Check that we don't support a 0 limit, which wouldn't make sense +(expect + {:errors {:limit "value may be nil, or if non-nil, value must be a valid integer greater than zero."}} + ((users/user->client :crowberto) :get 400 "task/" :limit 0 :offset 100)) + +;; Check that paging information is applied when provided and included in the response +(let [[task-hist-1 task-hist-2 task-hist-3 task-hist-4] (generate-tasks 4)] + (expect + [{:total 4, :limit 2, :offset 0 + :data (map (fn [{:keys [task]}] + (assoc default-task-history :task task)) + [task-hist-4 task-hist-3])} + {:total 4, :limit 2, :offset 2 + :data (map (fn [{:keys [task]}] + (assoc default-task-history :task task)) + [task-hist-2 task-hist-1])}] + (do + (db/delete! TaskHistory) + (tt/with-temp* [TaskHistory [task-1 task-hist-1] + TaskHistory [task-2 task-hist-2] + TaskHistory [task-3 task-hist-3] + TaskHistory [task-4 task-hist-4]] + (map tu/boolean-ids-and-timestamps + [((users/user->client :crowberto) :get 200 "task/" :limit 2 :offset 0) + ((users/user->client :crowberto) :get 200 "task/" :limit 2 :offset 2)]))))) + +;; Only superusers can query for TaskHistory +(expect + "You don't have permissions to do that." + ((users/user->client :rasta) :get 403 "task/")) + +;; Regular users can't query for a specific TaskHistory +(expect + "You don't have permissions to do that." + (tt/with-temp TaskHistory [task] + ((users/user->client :rasta) :get 403 (format "task/%s" (u/get-id task))))) + +;; Superusers querying for a TaskHistory that doesn't exist will get a 404 +(expect + "Not found." + ((users/user->client :crowberto) :get 404 (format "task/%s" Integer/MAX_VALUE))) + +;; Superusers querying for specific TaskHistory will get that task info +(expect + (merge default-task-history {:task "Test Task", :duration 100}) + (tt/with-temp TaskHistory [task {:task "Test Task" + :duration 100}] + (tu/boolean-ids-and-timestamps + ((users/user->client :crowberto) :get 200 (format "task/%s" (u/get-id task)))))) diff --git a/yarn.lock b/yarn.lock index f9ead6746ad6e7820ab1cec06894c2081e6c723f..6132985205478ea3100e74a9293fa2e4dd5a5e97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3713,11 +3713,6 @@ dom-serializer@0, dom-serializer@~0.1.0: domelementtype "~1.1.1" entities "~1.1.1" -dom-walk@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" - integrity sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg= - domain-browser@^1.1.1: version "1.1.7" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" @@ -5197,14 +5192,6 @@ global-dirs@^0.1.0: dependencies: ini "^1.3.4" -global@^4.3.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" - integrity sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8= - dependencies: - min-document "^2.19.0" - process "~0.5.1" - globals-docs@^2.3.0: version "2.4.0" resolved "https://registry.yarnpkg.com/globals-docs/-/globals-docs-2.4.0.tgz#f2c647544eb6161c7c38452808e16e693c2dafbb" @@ -8088,13 +8075,6 @@ mimic-fn@^1.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" integrity sha1-5md4PZLonb00KBi1IwudYqZyrRg= -min-document@^2.19.0: - version "2.19.0" - resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" - integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= - dependencies: - dom-walk "^0.1.0" - minimalistic-assert@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3" @@ -10161,11 +10141,6 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -process@~0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf" - integrity sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8= - progress@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" @@ -13798,14 +13773,6 @@ xdg-basedir@^3.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= -xhr-mock@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/xhr-mock/-/xhr-mock-2.4.1.tgz#cb502e3d50b8b2ec31bd61766ce516bfc1dd072f" - integrity sha1-y1AuPVC4suwxvWF2bOUWv8HdBy8= - dependencies: - global "^4.3.0" - url "^0.11.0" - xml-char-classes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/xml-char-classes/-/xml-char-classes-1.0.0.tgz#64657848a20ffc5df583a42ad8a277b4512bbc4d"