feat: list, create and start snapshots

This commit is contained in:
Sérgio Ramos 2017-10-13 20:56:08 +01:00 committed by Sérgio Ramos
parent 55811372fb
commit 8cdf8070e8
12 changed files with 391 additions and 36 deletions

View File

@ -229,7 +229,13 @@ const resolvers = {
startMachineFromSnapshot: (root, { id, name }) => startMachineFromSnapshot: (root, { id, name }) =>
api.machines.snapshots api.machines.snapshots
.startFromSnapshot({ id, name }) .startFromSnapshot({ id, name })
.then(() => resolvers.Query.machine(null, { id })) .then(() => resolvers.Query.machine(null, { id })),
deleteMachineSnapshot: async (root, { id, snapshot: name }) => {
const snapshot = await api.machines.snapshots.get({ id, name });
await api.machines.snapshots.destroy({ id, name });
return snapshot;
}
} }
}; };

View File

@ -25,6 +25,7 @@
"lodash.isstring": "^4.0.1", "lodash.isstring": "^4.0.1",
"lodash.sortby": "^4.7.0", "lodash.sortby": "^4.7.0",
"lunr": "^2.1.3", "lunr": "^2.1.3",
"moment": "^2.19.1",
"normalized-styled-components": "^1.0.17", "normalized-styled-components": "^1.0.17",
"param-case": "^2.1.1", "param-case": "^2.1.1",
"prop-types": "^15.6.0", "prop-types": "^15.6.0",

View File

@ -5,3 +5,5 @@ export { default as Network } from './network';
export { default as FirewallRule } from './firewall-rule'; export { default as FirewallRule } from './firewall-rule';
export { default as Resize } from './resize'; export { default as Resize } from './resize';
export { default as CreateSnapshot } from './create-snapshot'; export { default as CreateSnapshot } from './create-snapshot';
export { default as Snapshots } from './snapshots';
export { default as Snapshot } from './snapshot';

View File

@ -0,0 +1,56 @@
import React from 'react';
import titleCase from 'title-case';
import moment from 'moment';
import {
Card,
CardMeta,
CardAction,
CardTitle,
CardLabel,
CardView,
Checkbox,
FormGroup,
QueryBreakpoints,
StatusLoader
} from 'joyent-ui-toolkit';
const { SmallOnly, Small } = QueryBreakpoints;
const stateColor = {
QUEUED: 'blue',
CANCELED: 'grey',
FAILED: 'red',
CREATED: 'green'
};
export default ({ name, state, created, loading, last, first }) => (
<Card collapsed flat={!last} topMargin={first} bottomless={!last} gapless>
<CardView>
<CardMeta>
<CardAction>
<FormGroup name={name} reduxForm>
<Checkbox />
</FormGroup>
</CardAction>
<CardTitle>{name}</CardTitle>
<CardLabel>{moment.unix(created).fromNow()}</CardLabel>
{loading && (
<CardLabel>
<StatusLoader small />
</CardLabel>
)}
{!loading && (
<Small>
<CardLabel color={stateColor[state]}>{titleCase(state)}</CardLabel>
</Small>
)}
{!loading && (
<SmallOnly>
<CardLabel color={stateColor[state]} />
</SmallOnly>
)}
</CardMeta>
</CardView>
</Card>
);

View File

@ -0,0 +1,162 @@
import React from 'react';
import { Row, Col } from 'react-styled-flexboxgrid';
import forceArray from 'force-array';
import find from 'lodash.find';
import {
FormGroup,
Input,
FormLabel,
ViewContainer,
StatusLoader,
Select,
Message,
MessageTitle,
MessageDescription,
Button,
QueryBreakpoints
} from 'joyent-ui-toolkit';
import Item from './snapshot';
const { SmallOnly, Medium } = QueryBreakpoints;
export default ({
snapshots = [],
selected = [],
loading,
error,
handleChange = () => null,
onAction = () => null,
handleSubmit,
submitting = false,
pristine = true,
...rest
}) => {
const allowedActions = {
delete: selected.length > 0,
start: selected.length === 1
};
const handleActions = ev => {
ev.stopPropagation();
ev.preventDefault();
onAction({
name: ev.target.value,
items: selected
});
};
const _snapshots = forceArray(snapshots);
const _loading = !_snapshots.length &&
loading && (
<ViewContainer center>
<StatusLoader />
</ViewContainer>
);
const items = _snapshots.map((snapshot, i, all) => {
const { name } = snapshot;
const isSelected = Boolean(find(selected, ['name', name]));
const isSubmitting = isSelected && submitting;
return (
<Item
key={name}
{...snapshot}
last={all.length - 1 === i}
first={!i}
loading={isSubmitting}
/>
);
});
const _error = error &&
!submitting && (
<Message error>
<MessageTitle>Ooops!</MessageTitle>
<MessageDescription>{error}</MessageDescription>
</Message>
);
return (
<form
onChange={() => handleSubmit(ctx => handleChange(ctx))}
onSubmit={handleSubmit}
>
<Row between="xs">
<Col xs={8} sm={8} lg={6}>
<Row>
<Col xs={7} sm={7} md={6} lg={6}>
<FormGroup name="filter" reduxForm>
<FormLabel>Filter snapshots</FormLabel>
<Input
placeholder="Search for name or state"
disabled={pristine && !items.length}
fluid
/>
</FormGroup>
</Col>
<Col xs={5} sm={3} lg={3}>
<FormGroup name="sort" reduxForm>
<FormLabel>Sort</FormLabel>
<Select disabled={!items.length} fluid>
<option value="name">Name</option>
<option value="state">State</option>
<option value="created">Created</option>
<option value="updated">Updated</option>
</Select>
</FormGroup>
</Col>
</Row>
</Col>
<Col xs={4} sm={4} lg={6}>
<Row end="xs">
<Col xs={6} sm={4} md={3} lg={2}>
<FormGroup>
<FormLabel>&#8291;</FormLabel>
<Select
value="actions"
disabled={!items.length || !selected.length}
onChange={handleActions}
fluid
>
<option value="actions" selected disabled>
&#8801;
</option>
<option value="delete" disabled={!allowedActions.delete}>
Delete
</option>
<option value="start" disabled={!allowedActions.start}>
Start
</option>
</Select>
</FormGroup>
</Col>
<Col xs={6} sm={6} md={5} lg={2}>
<FormGroup>
<FormLabel>&#8291;</FormLabel>
<Button
type="button"
small
icon
fluid
onClick={() => onAction({ name: 'create' })}
>
<SmallOnly>+</SmallOnly>
<Medium>Create</Medium>
</Button>
</FormGroup>
</Col>
</Row>
</Col>
</Row>
{_loading}
{_error}
{items}
</form>
);
};

View File

@ -1,31 +1,50 @@
import React from 'react'; import React from 'react';
import ReactJson from 'react-json-view'; import moment from 'moment';
import PropTypes from 'prop-types';
import forceArray from 'force-array'; import forceArray from 'force-array';
import { connect } from 'react-redux';
import { compose, graphql } from 'react-apollo'; import { compose, graphql } from 'react-apollo';
import find from 'lodash.find'; import find from 'lodash.find';
import sortBy from 'lodash.sortby';
import get from 'lodash.get'; import get from 'lodash.get';
import { reduxForm, stopSubmit, startSubmit, change } from 'redux-form';
import { import {
ViewContainer, ViewContainer,
Title, Title,
StatusLoader,
Message, Message,
MessageTitle, MessageTitle,
MessageDescription MessageDescription
} from 'joyent-ui-toolkit'; } from 'joyent-ui-toolkit';
import GetSnapshots from '@graphql/list-snapshots.gql'; import GetSnapshots from '@graphql/list-snapshots.gql';
import StartSnapshot from '@graphql/start-from-snapshot.gql';
import RemoveSnapshot from '@graphql/remove-snapshot.gql';
import { Snapshots as SnapshotsList } from '@components/instances';
import GenIndex from '@state/gen-index';
const Snapshots = ({ snapshots = [], loading, error }) => { const SnapshotsListForm = reduxForm({
form: `snapshots-list`,
initialValues: {
sort: 'name'
}
})(SnapshotsList);
const Snapshots = ({
snapshots = [],
selected = [],
loading,
error,
handleAction
}) => {
const _title = <Title>Snapshots</Title>; const _title = <Title>Snapshots</Title>;
const _loading = !(loading && !forceArray(snapshots).length) ? null : (
<StatusLoader />
);
const _summary = !_loading && <ReactJson src={snapshots} />; const _values = forceArray(snapshots);
const _loading = !_values.length && loading;
const _error = !(error && !_loading) ? null : ( const _error = error &&
!_loading &&
!_values.length && (
<Message error> <Message error>
<MessageTitle>Ooops!</MessageTitle> <MessageTitle>Ooops!</MessageTitle>
<MessageDescription> <MessageDescription>
@ -35,20 +54,22 @@ const Snapshots = ({ snapshots = [], loading, error }) => {
); );
return ( return (
<ViewContainer center={Boolean(_loading)} main> <ViewContainer main>
{_title} {_title}
{_loading}
{_error} {_error}
{_summary} <SnapshotsListForm
snapshots={_values}
loading={_loading}
onAction={handleAction}
selected={selected}
/>
</ViewContainer> </ViewContainer>
); );
}; };
Snapshots.propTypes = {
loading: PropTypes.bool
};
export default compose( export default compose(
graphql(StartSnapshot, { name: 'start' }),
graphql(RemoveSnapshot, { name: 'remove' }),
graphql(GetSnapshots, { graphql(GetSnapshots, {
options: ({ match }) => ({ options: ({ match }) => ({
pollInterval: 1000, pollInterval: 1000,
@ -56,14 +77,108 @@ export default compose(
name: get(match, 'params.instance') name: get(match, 'params.instance')
} }
}), }),
props: ({ data: { loading, error, variables, ...rest } }) => ({ props: ({ data: { loading, error, variables, ...rest } }) => {
snapshots: get( const { name } = variables;
find(get(rest, 'machines', []), ['name', variables.name]), const instance = find(get(rest, 'machines', []), ['name', name]);
const snapshots = get(
instance,
'snapshots', 'snapshots',
[] []
), ).map(({ created, updated, ...rest }) => ({
...rest,
created: moment.utc(created).unix(),
updated: moment.utc(updated).unix()
}));
const index = GenIndex(
snapshots.map(({ name, ...rest }) => ({ ...rest, id: name }))
);
return {
index,
snapshots,
instance,
loading, loading,
error error
};
}
}),
connect(
(state, { index, snapshots = [], ...rest }) => {
const form = get(state, 'form.snapshots-list.values', {});
const filter = get(form, 'filter');
const sort = get(form, 'sort');
const values = filter
? index.search(filter).map(({ ref }) => find(snapshots, ['name', ref]))
: snapshots;
const selected = Object.keys(form)
.filter(key => Boolean(form[key]))
.map(name => find(values, ['name', name]))
.filter(Boolean)
.map(({ name }) => find(snapshots, ['name', name]))
.filter(Boolean);
return {
...rest,
snapshots: sortBy(values, value => get(value, sort)),
selected
};
},
(dispatch, { create, start, remove, instance, history, match }) => ({
handleAction: ({ name, items = [] }) => {
const form = 'snapshots-list';
const types = {
start: () =>
Promise.resolve(dispatch(startSubmit(form))).then(() =>
Promise.all(
items.map(({ name }) =>
start({ variables: { id: instance.id, snapshot: name } })
)
)
),
delete: () =>
Promise.resolve(dispatch(startSubmit(form))).then(() =>
Promise.all(
items.map(({ name }) =>
remove({ variables: { id: instance.id, snapshot: name } })
)
)
),
create: () =>
Promise.resolve(
history.push(`/instances/${instance.name}/snapshots/~create`)
)
};
const handleError = error => {
dispatch(
stopSubmit(form, {
_error: error.graphQLErrors
.map(({ message }) => message)
.join('\n')
}) })
);
};
const handleSuccess = () => {
dispatch(
items
.map(({ name: field }) => change(form, field, false))
.concat([stopSubmit(form)])
);
};
return (
types[name] &&
types[name]()
.then(handleSuccess)
.catch(handleError)
);
}
}) })
)
)(Snapshots); )(Snapshots);

View File

@ -5,6 +5,8 @@ query instance($name: String!) {
snapshots { snapshots {
name name
state state
created
updated
} }
} }
} }

View File

@ -0,0 +1,5 @@
mutation deleteMachineSnapshot($id: ID!, $snapshot: ID!) {
deleteMachineSnapshot(id: $id, snapshot: $snapshot) {
name
}
}

View File

@ -1,3 +1,5 @@
mutation startInstanceFromSnapshot($id: ID!, $snapshot: ID!) { mutation startInstanceFromSnapshot($id: ID!, $snapshot: ID!) {
startMachineFromSnapshot(id: $id, snapshot: $snapshot) startMachineFromSnapshot(id: $id, snapshot: $snapshot) {
id
}
} }

View File

@ -112,7 +112,7 @@ export default () => (
component={InstanceCreateSnapshot} component={InstanceCreateSnapshot}
/> />
<Route <Route
path="/instances/:instance/:section?/~create-snapshot" path="/instances/:instance/snapshots/~create"
exact exact
component={InstanceCreateSnapshot} component={InstanceCreateSnapshot}
/> />

View File

@ -6,18 +6,18 @@ import pascalCase from 'pascal-case';
export const breakpoints = { export const breakpoints = {
small: { small: {
upper: 768 upper: 767
}, },
medium: { medium: {
upper: 1024, upper: 1023,
lower: 769 lower: 768
}, },
large: { large: {
upper: 1200, upper: 1199,
lower: 1025 lower: 1024
}, },
xlarge: { xlarge: {
lower: 1201 lower: 1200
} }
}; };

View File

@ -17,8 +17,9 @@ const style = css`
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin: 0; margin-bottom: ${remcalc(8)};
padding: ${remcalc(15)} ${remcalc(18)}; margin-top: ${remcalc(8)};
padding: ${remcalc(13)} ${remcalc(18)};
position: relative; position: relative;
${typography.normal}; ${typography.normal};
@ -154,8 +155,7 @@ const style = css`
`}; `};
${is('small')` ${is('small')`
padding: ${remcalc(9)} ${remcalc(18)}; padding: ${remcalc(13)} ${remcalc(18)};
font-weight: 600;
`}; `};
${is('icon')` ${is('icon')`
@ -167,6 +167,10 @@ const style = css`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
`}; `};
${is('marginless')`
margin: 0;
`};
`; `;
const StyledButton = NButton.extend` const StyledButton = NButton.extend`