├── .dockerignore ├── dashboard ├── components │ ├── Main.css │ ├── TagList.css │ ├── Logo.css │ ├── Form.css │ ├── Footer.css │ ├── Main.js │ ├── Container.css │ ├── Container.js │ ├── Footer.js │ ├── Alert.css │ ├── Tag.css │ ├── Section.css │ ├── Tag.js │ ├── Alert.js │ ├── Card.css │ ├── Card.js │ ├── TagList.js │ ├── Button.js │ ├── Header.css │ ├── Section.js │ ├── Input.css │ ├── Button.css │ ├── Toggle.js │ ├── FeatureList.css │ ├── Header.js │ ├── Form.js │ ├── Toggle.css │ ├── FeatureDetail.css │ ├── Input.js │ ├── EnvironmentForm.js │ ├── FeatureForm.js │ ├── FeatureList.js │ ├── FeatureDetail.js │ └── Logo.js ├── .babelrc ├── utils │ ├── string.js │ └── api.js ├── public │ └── assets │ │ └── favicon.png ├── index.js ├── dev.Dockerfile ├── index.html ├── index.css ├── .eslintrc ├── webpack.dev.config.js ├── containers │ ├── Home.js │ ├── FeatureCreate.js │ ├── EnvironmentCreate.js │ ├── App.js │ └── FeatureDetail.js ├── webpack.prod.config.js └── package.json ├── scripts ├── lint.sh ├── develop.sh ├── migrate.sh ├── image.sh ├── test.sh ├── build.sh ├── report.sh ├── generate.sh ├── install.sh └── publish.sh ├── .gitignore ├── api ├── environment_resource.go ├── helper.go ├── event_resource.go ├── response.go ├── server.go ├── feature_resource.go └── middleware.go ├── store ├── schema │ ├── 2.sql │ ├── 3.sql │ ├── 4.sql │ ├── 1_init.sql │ └── schema.go ├── store.go ├── util.go ├── store_test.go ├── mysql_store_test.go ├── mysql_migration.go └── mysql_store.go ├── notifier ├── notifier.go ├── noop_notifier.go └── slack_notifier.go ├── Dockerfile ├── client ├── types.go ├── feature_cache_test.go ├── feature_cache.go └── client.go ├── dev.Dockerfile ├── models ├── testing.go ├── feature_created.go ├── environment_created.go ├── feature_deleted.go ├── environment_deleted.go ├── feature_toggled.go ├── events.go ├── environment_deleted_test.go ├── feature_deleted_test.go ├── environments_ordered.go ├── user_created.go ├── environments_ordered_test.go └── state.go ├── .circleci └── config.yml ├── docker-compose.yml ├── Makefile ├── LICENSE ├── go.mod ├── CHANGES.md ├── main.go ├── go.sum └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /dashboard/components/Main.css: -------------------------------------------------------------------------------- 1 | .lk-main { 2 | margin-top: 5vh; 3 | flex: 1; 4 | } 5 | -------------------------------------------------------------------------------- /dashboard/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets":[ 3 | "es2015", "react" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /dashboard/utils/string.js: -------------------------------------------------------------------------------- 1 | export function capitalize(s) { 2 | return s[0].toUpperCase() + s.slice(1) 3 | } 4 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | go vet 8 | -------------------------------------------------------------------------------- /dashboard/components/TagList.css: -------------------------------------------------------------------------------- 1 | .lk-tag-list__status-list { 2 | display: flex; 3 | align-items: center; 4 | } 5 | -------------------------------------------------------------------------------- /dashboard/public/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MEDIGO/laika/HEAD/dashboard/public/assets/favicon.png -------------------------------------------------------------------------------- /scripts/develop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | docker-compose up 8 | -------------------------------------------------------------------------------- /scripts/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | go run main.go migrate 8 | -------------------------------------------------------------------------------- /dashboard/components/Logo.css: -------------------------------------------------------------------------------- 1 | .lk-logo { 2 | height: 18px; 3 | fill: currentColor; 4 | vertical-align: text-bottom; 5 | } 6 | -------------------------------------------------------------------------------- /dashboard/components/Form.css: -------------------------------------------------------------------------------- 1 | .lk-form__controls { 2 | border-top: 1px solid #e9ebec; 3 | margin-top: 20px; 4 | padding-top: 20px; 5 | } 6 | -------------------------------------------------------------------------------- /scripts/image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | docker build --pull --rm -t ${DOCKER_USER}/laika . 8 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | docker-compose run laika go test -v ./... -cover -coverprofile=combined_coverage.out 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | application-build-* 4 | bin/ 5 | combined_coverage.out 6 | coverage.out 7 | node_modules/ 8 | bundle.js 9 | dashboard/public/index.html 10 | /data 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/laika . 8 | (cd dashboard && npm run build) 9 | -------------------------------------------------------------------------------- /api/environment_resource.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/labstack/echo" 5 | ) 6 | 7 | func ListEnvironments(c echo.Context) error { 8 | return OK(c, getState(c).Environments) 9 | } 10 | -------------------------------------------------------------------------------- /dashboard/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import App from './containers/App' 4 | 5 | import './index.css' 6 | 7 | render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /store/schema/2.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE `feature_status_history` DROP COLUMN `timestamp`; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE `feature_status_history` ADD COLUMN `timestamp` DATE NOT NULL; 8 | -------------------------------------------------------------------------------- /scripts/report.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | 6 | if [[ -x "${COVERALLS_TOKEN}" ]]; then 7 | goveralls -coverprofile=combined_coverage.out -service=circle-ci -repotoken=${COVERALLS_TOKEN} 8 | fi 9 | -------------------------------------------------------------------------------- /dashboard/dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.9.1-alpine 2 | 3 | RUN mkdir -p /usr/src/app 4 | 5 | WORKDIR /usr/src/app/ 6 | 7 | COPY package.json /usr/src/app/ 8 | COPY yarn.lock /usr/src/app/ 9 | 10 | RUN yarn install 11 | 12 | COPY . /usr/src/app/ 13 | -------------------------------------------------------------------------------- /notifier/notifier.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | // Notifier describes a notification sender. 4 | type Notifier interface { 5 | // NotifyStatusChange notifies a change in the status of a flag. 6 | NotifyStatusChange(feature string, status bool, environment string) error 7 | } 8 | -------------------------------------------------------------------------------- /dashboard/components/Footer.css: -------------------------------------------------------------------------------- 1 | .lk-footer { 2 | border-top: 1px solid #e2dee6; 3 | color: #625471; 4 | display: flex; 5 | justify-content: space-between; 6 | margin-top: 5vh; 7 | } 8 | 9 | .lk-footer a { 10 | color: #625471; 11 | text-decoration: none; 12 | } 13 | -------------------------------------------------------------------------------- /scripts/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | if ! which go-bindata > /dev/null 2>&1 ; then 8 | go get github.com/jteeuwen/go-bindata/... 9 | fi 10 | 11 | go-bindata -pkg schema -o store/schema/schema.go -ignore \.go store/schema/... 12 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | go mod download 8 | (cd dashboard && sudo apt install curl && curl -sL https://deb.nodesource.com/setup_6.x | sudo bash - && sudo apt-get install -y nodejs && sudo apt-get install -y npm && sudo npm install) 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | RUN apk add --update ca-certificates && \ 3 | rm -rf /var/cache/apk/* /tmp/* 4 | 5 | RUN update-ca-certificates 6 | 7 | RUN apk update && \ 8 | apk add ca-certificates 9 | 10 | COPY bin/laika / 11 | COPY dashboard/public /dashboard/public/ 12 | 13 | ENTRYPOINT ["/laika"] 14 | CMD ["run"] 15 | -------------------------------------------------------------------------------- /dashboard/components/Main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { node } from 'prop-types' 3 | 4 | import './Main.css' 5 | 6 | const Main = ({ children }) =>
{children}
7 | 8 | Main.propTypes = { 9 | children: node 10 | } 11 | 12 | Main.defaultProps = { 13 | children: null 14 | } 15 | 16 | export default Main 17 | -------------------------------------------------------------------------------- /dashboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Laika 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /dashboard/components/Container.css: -------------------------------------------------------------------------------- 1 | .lk-container { 2 | display: flex; 3 | justify-content: space-between; 4 | width: 100%; 5 | margin: auto; 6 | max-width: 1024px; 7 | padding: 2vw; 8 | box-sizing: border-box; 9 | } 10 | 11 | @media screen and (orientation: landscape) and (min-width: 1024px) { 12 | .lk-container { 13 | padding: 1vw; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/types.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "time" 4 | 5 | // Feature represents a Feature. 6 | type Feature struct { 7 | CreatedAt time.Time `json:"created_at"` 8 | Name string `json:"name"` 9 | Status map[string]bool `json:"status"` 10 | } 11 | 12 | // Error represents an API error. 13 | type Error struct { 14 | Message string `json:"message"` 15 | } 16 | -------------------------------------------------------------------------------- /dashboard/components/Container.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { node } from 'prop-types' 3 | 4 | import './Container.css' 5 | 6 | const Container = ({ children }) => ( 7 |
{children}
8 | ) 9 | 10 | Container.propTypes = { 11 | children: node 12 | } 13 | 14 | Container.defaultProps = { 15 | children: null 16 | } 17 | 18 | export default Container 19 | -------------------------------------------------------------------------------- /dashboard/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Container from './Container' 3 | 4 | import './Footer.css' 5 | 6 | const Footer = () => ( 7 | 13 | ) 14 | 15 | export default Footer 16 | -------------------------------------------------------------------------------- /store/schema/3.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE IF NOT EXISTS `user`( 4 | `id` INT NOT NULL AUTO_INCREMENT, 5 | `username` VARCHAR(255) NOT NULL, 6 | `password_hash` VARCHAR(255) NOT NULL, 7 | `created_at` DATE NOT NULL, 8 | `updated_at` DATE, 9 | 10 | PRIMARY KEY (id), 11 | UNIQUE (username) 12 | ); 13 | 14 | -- +migrate Down 15 | 16 | DROP TABLE IF EXISTS user; 17 | -------------------------------------------------------------------------------- /dashboard/components/Alert.css: -------------------------------------------------------------------------------- 1 | .lk-alert { 2 | color: rgba(0,0,0,.7); 3 | font-weight: 400; 4 | padding: 10px 20px; 5 | border-radius: 0; 6 | font-size: 15px; 7 | line-height: 1.4; 8 | box-shadow: 0 1px 1px rgba(0,0,0,.05); 9 | border: 1px solid #e1d697; 10 | margin-bottom: 20px; 11 | border-radius: 3px; 12 | } 13 | 14 | .lk-alert--danger { 15 | background: #fdf6f5; 16 | border-color: #e7c0bc; 17 | } 18 | -------------------------------------------------------------------------------- /dashboard/components/Tag.css: -------------------------------------------------------------------------------- 1 | .lk-tag { 2 | background-color: #ededed; 3 | border-radius: 2px; 4 | box-shadow: inset 0 -1px 0 rgba(27,31,35,0.12); 5 | color: #333333; 6 | font-size: 12px; 7 | font-weight: bold; 8 | line-height: 1; 9 | padding: 3px 4px; 10 | } 11 | 12 | .lk-tag + .lk-tag { 13 | margin-left: 10px; 14 | } 15 | 16 | .lk-tag--success { 17 | background-color: #bfe5bf; 18 | color: #2a332a; 19 | } 20 | -------------------------------------------------------------------------------- /dashboard/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #2f2936; 3 | font-family: "Helvetica Neue", Helvetica; 4 | font-size: 16px; 5 | line-height: 24px; 6 | margin: 0; 7 | } 8 | 9 | .app { 10 | display: flex; 11 | flex-flow: column nowrap; 12 | min-height: 100vh; 13 | } 14 | 15 | a:focus { 16 | display: block; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | 23 | h1, 24 | h2, 25 | h3 { 26 | margin: 0; 27 | } 28 | -------------------------------------------------------------------------------- /store/schema/4.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE IF NOT EXISTS `events` ( 4 | `id` INT NOT NULL AUTO_INCREMENT, 5 | `time` TIMESTAMP NOT NULL, 6 | `type` VARCHAR(255) NOT NULL, 7 | `data` MEDIUMTEXT NOT NULL, 8 | 9 | PRIMARY KEY (`id`), 10 | KEY(`time`), 11 | KEY(`type`) 12 | ) DEFAULT CHARSET=utf8; 13 | 14 | -- +migrate Down 15 | 16 | DROP TABLE IF EXISTS `events`; 17 | -------------------------------------------------------------------------------- /dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine 2 | 3 | # Required for running go tests 4 | RUN apk --no-cache add gcc g++ make ca-certificates 5 | 6 | WORKDIR /go/src/github.com/MEDIGO/laika 7 | 8 | COPY go.mod /go/src/github.com/MEDIGO/laika 9 | COPY go.sum /go/src/github.com/MEDIGO/laika 10 | 11 | RUN go mod download 12 | 13 | RUN go get github.com/ivpusic/rerun 14 | 15 | COPY . /go/src/github.com/MEDIGO/laika 16 | 17 | EXPOSE 8000 18 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | if [[ -z ${CIRCLE_SHA1:-} ]]; then 8 | COMMIT=$(git rev-parse HEAD) 9 | else 10 | COMMIT=${CIRCLE_SHA1} 11 | fi 12 | 13 | docker tag ${DOCKER_USER}/laika:latest ${DOCKER_USER}/laika:${COMMIT} 14 | docker login -u ${DOCKER_USER} -p ${DOCKER_PASS} 15 | docker push ${DOCKER_USER}/laika:latest 16 | docker push ${DOCKER_USER}/laika:${COMMIT} 17 | -------------------------------------------------------------------------------- /dashboard/components/Section.css: -------------------------------------------------------------------------------- 1 | .lk-section__title { 2 | color: #968ba0; 3 | font-size: 12px; 4 | letter-spacing: 0.8px; 5 | margin-bottom: 2px; 6 | text-transform: uppercase; 7 | } 8 | 9 | .lk-section__content { 10 | border-radius: 3px; 11 | border: 1px solid #e2dee6; 12 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03); 13 | margin: 0; 14 | } 15 | 16 | .lk-section__item + .lk-section__item { 17 | border-top: 1px solid #e2dee6; 18 | } 19 | -------------------------------------------------------------------------------- /dashboard/components/Tag.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { oneOf, node } from 'prop-types' 3 | 4 | import './Tag.css' 5 | 6 | const Tag = ({ children, type }) => 7 | {children} 8 | 9 | Tag.propTypes = { 10 | children: node, 11 | type: oneOf(['success', 'default']) 12 | } 13 | 14 | Tag.defaultProps = { 15 | children: null, 16 | type: 'default' 17 | } 18 | 19 | export default Tag 20 | -------------------------------------------------------------------------------- /dashboard/components/Alert.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { oneOf, node } from 'prop-types' 3 | 4 | import './Alert.css' 5 | 6 | const Alert = ({ type, children }) => { 7 | return
{children}
8 | } 9 | 10 | Alert.propTypes = { 11 | type: oneOf(['danger']), 12 | children: node 13 | } 14 | 15 | Alert.defaultProps = { 16 | type: 'danger', 17 | children: null 18 | } 19 | 20 | export default Alert 21 | -------------------------------------------------------------------------------- /dashboard/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard", 4 | "standard-react" 5 | ], 6 | "plugins": [ 7 | "react", 8 | "jsx-a11y", 9 | "import" 10 | ], 11 | "rules": { 12 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 13 | "space-before-function-paren": ["error", {"named": "never", "anonymous": "never"}] 14 | }, 15 | "env": { 16 | "browser": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /dashboard/components/Card.css: -------------------------------------------------------------------------------- 1 | .lk-card { 2 | border-radius: 3px; 3 | border: 1px solid #e2dee6; 4 | box-shadow: 0 1px 0 rgba(0,0,0,.03); 5 | margin: auto; 6 | width: 100%; 7 | } 8 | 9 | .lk-card__title { 10 | background: #fbfbfc; 11 | border-bottom: 1px solid #e2dee6; 12 | font-size: 18px; 13 | font-weight: bold; 14 | margin: 0; 15 | padding: 15px 30px 10px; 16 | } 17 | 18 | .lk-card__content { 19 | background: #fff; 20 | padding: 15px 30px 15px; 21 | } 22 | -------------------------------------------------------------------------------- /api/helper.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/MEDIGO/laika/models" 5 | "github.com/labstack/echo" 6 | ) 7 | 8 | // RequestID returns the request ID from the current context. 9 | func RequestID(c echo.Context) string { 10 | val := c.Get("request_id") 11 | if val == nil { 12 | return "unknown" 13 | } 14 | 15 | return val.(string) 16 | } 17 | 18 | func getState(c echo.Context) *models.State { 19 | state, _ := c.Get("state").(*models.State) 20 | return state 21 | } 22 | -------------------------------------------------------------------------------- /notifier/noop_notifier.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | // NOOPNotifier is a notifier that doesn't perform any notifications. 4 | type NOOPNotifier struct{} 5 | 6 | // NewNOOPNotifier creates a new NOOPNotifier. 7 | func NewNOOPNotifier() Notifier { 8 | return &NOOPNotifier{} 9 | } 10 | 11 | // NotifyStatusChange doesn't perform any operation and always returns nil. 12 | func (n *NOOPNotifier) NotifyStatusChange(featureName string, status bool, environmentName string) error { 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /dashboard/components/Card.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { string, node } from 'prop-types' 3 | 4 | import './Card.css' 5 | 6 | const Card = ({ title, children }) => 7 |
8 |

{title}

9 |
{children}
10 |
11 | 12 | Card.propTypes = { 13 | title: string, 14 | children: node 15 | } 16 | 17 | Card.defaultProps = { 18 | title: null, 19 | children: null 20 | } 21 | 22 | export default Card 23 | -------------------------------------------------------------------------------- /models/testing.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func requireValid(t *testing.T, s *State, e Event) Event { 10 | valErr, err := e.Validate(s) 11 | 12 | require.NoError(t, valErr, "event must be valid") 13 | require.NoError(t, err, "error during validaton") 14 | 15 | return e 16 | } 17 | 18 | func requireInvalid(t *testing.T, s *State, e Event) { 19 | valErr, err := e.Validate(s) 20 | 21 | require.Error(t, valErr, "validation must fail") 22 | require.NoError(t, err, "error during validaton") 23 | } 24 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "github.com/MEDIGO/laika/models" 5 | ) 6 | 7 | // Store describes a data store. 8 | type Store interface { 9 | // Persist persists an event and returns its ID. 10 | Persist(eventType string, data string) (int64, error) 11 | // State returns the current state. 12 | State() (*models.State, error) 13 | // Migrate migrates the database schema to the latest available version. 14 | Migrate() error 15 | // Ping checks the contnectivity with the store. 16 | Ping() error 17 | // Reset removes all stored data. 18 | Reset() error 19 | } 20 | -------------------------------------------------------------------------------- /dashboard/components/TagList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { arrayOf, shape, string, bool } from 'prop-types' 3 | 4 | import Tag from './Tag' 5 | import './TagList.css' 6 | 7 | const TagList = ({ tags }) => 8 | 9 | {tags.map(tag => ( 10 | 11 | {tag.name} 12 | 13 | ))} 14 | 15 | 16 | TagList.propTypes = { 17 | tags: arrayOf( 18 | shape({ 19 | name: string, 20 | enabled: bool 21 | }) 22 | ) 23 | } 24 | 25 | export default TagList 26 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:1 6 | working_directory: /go/src/github.com/MEDIGO/laika 7 | steps: 8 | - checkout 9 | - run: go mod download 10 | - run: touch .env 11 | - run: pwd 12 | - run: ls 13 | - run: make install 14 | - run: make build 15 | - run: make report 16 | - setup_remote_docker: 17 | docker_layer_caching: false 18 | - run: make image 19 | - run: make publish 20 | -------------------------------------------------------------------------------- /dashboard/components/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { string, oneOf, func } from 'prop-types' 3 | 4 | import './Button.css' 5 | 6 | const Button = ({ label, type, onClick }) => { 7 | const state = onClick ? type : 'hidden' 8 | 9 | return ( 10 | 13 | ) 14 | } 15 | 16 | Button.propTypes = { 17 | label: string.isRequired, 18 | type: oneOf(['primary', 'default']), 19 | onClick: func 20 | } 21 | 22 | Button.defaultProps = { 23 | type: 'default', 24 | onClick: null 25 | } 26 | 27 | export default Button 28 | -------------------------------------------------------------------------------- /dashboard/components/Header.css: -------------------------------------------------------------------------------- 1 | .lk-header { 2 | background-color: #342c3e; 3 | color: #fff; 4 | box-shadow: 0 2px 0 rgba(0, 0, 0, 0.03); 5 | } 6 | 7 | .lk-header__nav { 8 | width: 100%; 9 | } 10 | 11 | .lk-header .lk-header__nav-items { 12 | align-items: center; 13 | display: flex; 14 | justify-content: space-between; 15 | list-style-type: none; 16 | margin: 0; 17 | padding: 0; 18 | } 19 | 20 | .lk-header a { 21 | color: #b9b2d0; 22 | text-decoration: none; 23 | font-size: calc(1rem * 0.9); 24 | } 25 | 26 | .lk-header a:hover { 27 | color: #fff; 28 | cursor: pointer; 29 | transition: color 0.5s ease-in-out; 30 | } 31 | -------------------------------------------------------------------------------- /dashboard/components/Section.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { string, node } from 'prop-types' 3 | 4 | import './Section.css' 5 | 6 | export default function Section({ title, children }) { 7 | const items = children.map((c, i) => ( 8 |
9 | {c} 10 |
11 | )) 12 | 13 | return ( 14 |
15 |

{title}

16 |
{items}
17 |
18 | ) 19 | } 20 | 21 | Section.propTypes = { 22 | title: string.isRequired, 23 | children: node.isRequired 24 | } 25 | -------------------------------------------------------------------------------- /dashboard/components/Input.css: -------------------------------------------------------------------------------- 1 | .lk-input + .lk-input { 2 | margin-top: 15px; 3 | } 4 | 5 | .lk-input__label { 6 | display: block; 7 | font-weight: bold; 8 | margin-bottom: 5px; 9 | } 10 | 11 | .lk-input__input { 12 | border-radius: 3px; 13 | border: 1px solid #c9c0d1; 14 | box-shadow: inset 0 2px 0 rgba(0,0,0,.04); 15 | box-sizing: border-box; 16 | color: #493e54; 17 | height: auto; 18 | padding: 8px 12px 7px; 19 | position: relative; 20 | width: 100%; 21 | } 22 | 23 | .lk-input__input:focus { 24 | border-color: #a598b2; 25 | box-shadow: inset 0 2px 0 rgba(0,0,0,.04), 0 0 6px rgba(177,171,225,.3); 26 | outline: none; 27 | } 28 | -------------------------------------------------------------------------------- /store/util.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "time" 7 | ) 8 | 9 | func Bool(b bool) *bool { 10 | x := b 11 | return &x 12 | } 13 | 14 | func Int(i int64) *int64 { 15 | x := i 16 | return &x 17 | } 18 | 19 | func String(s string) *string { 20 | x := s 21 | return &x 22 | } 23 | 24 | func Time(t time.Time) *time.Time { 25 | x := t 26 | return &x 27 | } 28 | 29 | func Token() string { 30 | return Randstr(13) 31 | } 32 | 33 | func Randstr(len int) string { 34 | b := make([]byte, len) 35 | if _, err := rand.Read(b); err != nil { 36 | panic(err) 37 | } 38 | 39 | return hex.EncodeToString(b)[:len] 40 | } 41 | -------------------------------------------------------------------------------- /dashboard/components/Button.css: -------------------------------------------------------------------------------- 1 | .lk-button { 2 | background: white; 3 | border-radius: 3px; 4 | border: 1px solid #413496; 5 | color: #413496; 6 | cursor: pointer; 7 | font-size: 14px; 8 | padding: 8px 16px; 9 | } 10 | 11 | .lk-button + .lk-button { 12 | margin-left: 8px; 13 | } 14 | 15 | .lk-button:focus { 16 | outline: 0; 17 | } 18 | 19 | .lk-button--primary { 20 | background: #6c5fc7; 21 | border-color: #413496; 22 | color: #fff; 23 | } 24 | 25 | .lk-button--primary:hover { 26 | background-color: #5b4cc0; 27 | } 28 | 29 | .lk-button--hidden { 30 | display: none; 31 | } 32 | 33 | .lk-button--hidden + .lk-button { 34 | margin-left: 0; 35 | } 36 | -------------------------------------------------------------------------------- /dashboard/components/Toggle.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { string, bool, func } from 'prop-types' 3 | 4 | import './Toggle.css' 5 | 6 | export default function Toggle({ name, value, onChange }) { 7 | const handleChange = e => { 8 | onChange(name, e.target.checked) 9 | } 10 | 11 | return ( 12 | 22 | ) 23 | } 24 | 25 | Toggle.propTypes = { 26 | name: string.isRequired, 27 | value: bool.isRequired, 28 | onChange: func.isRequired 29 | } 30 | -------------------------------------------------------------------------------- /dashboard/components/FeatureList.css: -------------------------------------------------------------------------------- 1 | .lk-feature-list { 2 | width: 100%; 3 | } 4 | 5 | .lk-feature-list a { 6 | text-decoration: none; 7 | padding: 2vw; 8 | display: block; 9 | border-radius: 3px; 10 | } 11 | 12 | @media screen and (orientation: landscape) and (min-width: 1024px) { 13 | .lk-feature-list a { 14 | padding: 1vw; 15 | } 16 | } 17 | 18 | .lk-feature-list a:hover { 19 | background: #f8fafd; 20 | cursor: pointer; 21 | } 22 | 23 | .lk-feature-list__name { 24 | color: #2f2936; 25 | display: flex; 26 | font-weight: bold; 27 | justify-content: space-between; 28 | } 29 | 30 | .lk-feature-list__item-name { 31 | word-break: break-all; 32 | } 33 | 34 | .lk-feature-list__time { 35 | font-size: 14px; 36 | color: #7c6a8e; 37 | } 38 | -------------------------------------------------------------------------------- /dashboard/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | entry: './index.js', 6 | output: { 7 | path: path.resolve(__dirname, 'public'), 8 | filename: 'assets/bundle.js', 9 | publicPath: '/' 10 | }, 11 | plugins: [ 12 | new HtmlWebpackPlugin({ 13 | template: './index.html', 14 | inject: 'body' 15 | }) 16 | ], 17 | module: { 18 | loaders: [ 19 | { 20 | test: /\.js$/, 21 | loader: 'eslint-loader', 22 | exclude: /node_modules/, 23 | enforce: 'pre' 24 | }, 25 | { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, 26 | { test: /\.css$/, use: ['style-loader', 'css-loader'] } 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | 8 | "github.com/MEDIGO/laika/models" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func testStoreEvents(t *testing.T, store Store) { 13 | event := models.EnvironmentCreated{Name: "some-env"} 14 | encoded, err := json.Marshal(&event) 15 | require.NoError(t, err) 16 | 17 | _, err = store.Persist("environment_created", string(encoded)) 18 | require.NoError(t, err) 19 | 20 | state, err := store.State() 21 | require.NoError(t, err) 22 | require.Len(t, state.Environments, 1) 23 | require.Equal(t, "some-env", state.Environments[0].Name) 24 | } 25 | 26 | func getenv(name, val string) string { 27 | if found := os.Getenv(name); found != "" { 28 | return found 29 | } 30 | return val 31 | } 32 | -------------------------------------------------------------------------------- /dashboard/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | 4 | import Container from './Container' 5 | import Logo from './Logo' 6 | import './Header.css' 7 | 8 | const Header = () => ( 9 |
10 | 11 | 26 | 27 |
28 | ) 29 | 30 | export default Header 31 | -------------------------------------------------------------------------------- /store/mysql_store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestMySQLStore(t *testing.T) { 10 | host := getenv("LAIKA_TEST_MYSQL_HOST", "") 11 | if host == "" { 12 | t.Skip("Skipping store MySQL test") 13 | } 14 | 15 | port := getenv("LAIKA_TEST_MYSQL_PORT", "3306") 16 | username := getenv("LAIKA_TEST_MYSQL_USERNAME", "root") 17 | password := getenv("LAIKA_TEST_MYSQL_PASSWORD", "root") 18 | database := getenv("LAIKA_TEST_MYSQL_DBNAME", "laika") 19 | 20 | store, err := NewMySQLStore(username, password, host, port, database) 21 | require.NoError(t, err) 22 | 23 | err = store.Migrate() 24 | require.NoError(t, err) 25 | 26 | require.NoError(t, store.Reset()) 27 | testStoreEvents(t, store) 28 | 29 | require.NoError(t, store.Reset()) 30 | } 31 | -------------------------------------------------------------------------------- /dashboard/containers/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withRouter } from 'react-router-dom' 3 | import FeatureList from '../components/FeatureList' 4 | import { listFeatures, listEnvironments } from '../utils/api' 5 | 6 | class Home extends Component { 7 | constructor(props) { 8 | super(props) 9 | 10 | this.state = { 11 | environments: [], 12 | features: [] 13 | } 14 | } 15 | 16 | componentDidMount() { 17 | Promise.all([listEnvironments(), listFeatures()]).then( 18 | ([environments, features]) => this.setState({ environments, features }) 19 | ) 20 | } 21 | 22 | render() { 23 | return ( 24 | 28 | ) 29 | } 30 | } 31 | 32 | export default withRouter(Home) 33 | -------------------------------------------------------------------------------- /models/feature_created.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | ) 9 | 10 | type FeatureCreated struct { 11 | Name string `json:"name"` 12 | } 13 | 14 | func (e *FeatureCreated) Validate(s *State) (error, error) { 15 | if e.Name == "" { 16 | return errors.New("Name must not be empty"), nil 17 | } 18 | 19 | if s.getFeatureByName(e.Name) != nil { 20 | return errors.New("Name is already in use"), nil 21 | } 22 | 23 | return nil, nil 24 | } 25 | 26 | func (e *FeatureCreated) Update(s *State, t time.Time) *State { 27 | state := *s 28 | state.Features = append(state.Features, Feature{ 29 | Name: e.Name, 30 | CreatedAt: t, 31 | }) 32 | return &state 33 | } 34 | 35 | func (e *FeatureCreated) PrePersist(*State) (Event, error) { 36 | return e, nil 37 | } 38 | 39 | func (*FeatureCreated) Notify(*State, notifier.Notifier) { 40 | } 41 | -------------------------------------------------------------------------------- /models/environment_created.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | ) 9 | 10 | type EnvironmentCreated struct { 11 | Name string `json:"name"` 12 | } 13 | 14 | func (e *EnvironmentCreated) Validate(s *State) (error, error) { 15 | if e.Name == "" { 16 | return errors.New("Name must not be empty"), nil 17 | } 18 | 19 | if s.getEnvByName(e.Name) != nil { 20 | return errors.New("Name is already in use"), nil 21 | } 22 | 23 | return nil, nil 24 | } 25 | 26 | func (e *EnvironmentCreated) Update(s *State, t time.Time) *State { 27 | state := *s 28 | state.Environments = append(state.Environments, Environment{ 29 | Name: e.Name, 30 | CreatedAt: t, 31 | }) 32 | return &state 33 | } 34 | 35 | func (e *EnvironmentCreated) PrePersist(*State) (Event, error) { 36 | return e, nil 37 | } 38 | 39 | func (*EnvironmentCreated) Notify(*State, notifier.Notifier) { 40 | } 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | laika: 5 | build: 6 | context: ./ 7 | dockerfile: dev.Dockerfile 8 | command: rerun -a run -i dashboard,node_modules,bin,public,.git 9 | env_file: .env 10 | volumes: 11 | - .:/go/src/github.com/MEDIGO/laika 12 | ports: 13 | - "8000:8000" 14 | depends_on: 15 | - db 16 | links: 17 | - db 18 | dashboard: 19 | build: 20 | context: ./dashboard 21 | dockerfile: dev.Dockerfile 22 | command: yarn run watch 23 | volumes: 24 | - ./dashboard:/usr/src/app 25 | - node_modules:/usr/src/app/node_modules 26 | db: 27 | hostname: db 28 | image: mysql:5.7 29 | ports: 30 | - "3306:3306" 31 | volumes: 32 | - ./data/db:/var/lib/mysql 33 | environment: 34 | MYSQL_ROOT_PASSWORD: root 35 | MYSQL_DATABASE: laika 36 | MYSQL_PASSWORD: root 37 | volumes: 38 | node_modules: 39 | -------------------------------------------------------------------------------- /dashboard/components/Form.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { string, func, node } from 'prop-types' 3 | 4 | import Alert from './Alert' 5 | import Button from './Button' 6 | 7 | import './Form.css' 8 | 9 | const Form = ({ submitText, onSubmit, errorText, children }) => { 10 | const handleSubmit = e => { 11 | e.preventDefault() 12 | onSubmit(e) 13 | } 14 | 15 | return ( 16 |
17 | {errorText ? {errorText} : null} 18 | 19 | {children} 20 |
21 |
23 |
24 | ) 25 | } 26 | 27 | Form.propTypes = { 28 | submitText: string, 29 | onSubmit: func.isRequired, 30 | errorText: string, 31 | children: node 32 | } 33 | 34 | Form.defaultProps = { 35 | submitText: 'Submit', 36 | errorText: null, 37 | children: null 38 | } 39 | 40 | export default Form 41 | -------------------------------------------------------------------------------- /models/feature_deleted.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | ) 9 | 10 | type FeatureDeleted struct { 11 | Name string `json:"name"` 12 | } 13 | 14 | func (e *FeatureDeleted) Validate(s *State) (error, error) { 15 | if s.getFeatureByName(e.Name) == nil { 16 | return errors.New("Bad feature"), nil 17 | } 18 | 19 | return nil, nil 20 | } 21 | 22 | func (e *FeatureDeleted) Update(s *State, t time.Time) *State { 23 | state := *s 24 | state.Features = []Feature{} 25 | 26 | for _, feature := range s.Features { 27 | if feature.Name != e.Name { 28 | state.Features = append(state.Features, feature) 29 | } 30 | } 31 | 32 | for _, env := range s.Environments { 33 | delete(state.Enabled, EnvFeature{env.Name, e.Name}) 34 | } 35 | 36 | return &state 37 | } 38 | 39 | func (e *FeatureDeleted) PrePersist(*State) (Event, error) { 40 | return e, nil 41 | } 42 | 43 | func (*FeatureDeleted) Notify(*State, notifier.Notifier) { 44 | } 45 | -------------------------------------------------------------------------------- /dashboard/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const MinifyPlugin = require('uglifyjs-webpack-plugin'); 5 | 6 | module.exports = { 7 | entry: './index.js', 8 | output: { 9 | path: path.resolve(__dirname, 'public'), 10 | filename: 'assets/bundle.js', 11 | publicPath: '/', 12 | }, 13 | plugins: [ 14 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 15 | new webpack.DefinePlugin({ 16 | 'process.env.NODE_ENV': JSON.stringify('production') 17 | }), 18 | new HtmlWebpackPlugin({ 19 | template: './index.html', 20 | inject: 'body' 21 | }), 22 | new MinifyPlugin() 23 | ], 24 | module: { 25 | loaders: [ 26 | { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, 27 | { test: /\.jsx$/, loader: 'babel-loader', exclude: /node_modules/ }, 28 | { test: /\.css$/, use: ['style-loader', 'css-loader'] }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: install build report publish clean 2 | .PHONY: all 3 | 4 | build: 5 | @echo "Building source code..." 6 | @scripts/build.sh 7 | .PHONY: build 8 | 9 | install: 10 | @echo "Installing dependencies..." 11 | @scripts/install.sh 12 | .PHONY: install 13 | 14 | generate: 15 | @echo "Generating source code..." 16 | @scripts/generate.sh 17 | .PHONY: generate 18 | 19 | lint: 20 | @echo "Linting sourcecode..." 21 | @scripts/lint.sh 22 | .PHONY: lint 23 | 24 | test: 25 | @echo "Running tests..." 26 | @scripts/test.sh 27 | .PHONY: test 28 | 29 | develop: 30 | @echo "Running server..." 31 | @scripts/develop.sh 32 | .PHONY: develop 33 | 34 | report: 35 | @echo "Reporting coverage..." 36 | @scripts/report.sh 37 | .PHONY: report 38 | 39 | image: 40 | @echo "Building Docker image..." 41 | @scripts/image.sh 42 | .PHONY: image 43 | 44 | publish: 45 | @echo "Publishing docker image..." 46 | @scripts/publish.sh 47 | .PHONY: publish 48 | 49 | clean: 50 | @echo "Cleaning environment..." 51 | @rm -rf bin public 52 | .PHONY: clean 53 | -------------------------------------------------------------------------------- /models/environment_deleted.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | ) 9 | 10 | type EnvironmentDeleted struct { 11 | Name string `json:"name"` 12 | } 13 | 14 | func (e *EnvironmentDeleted) Validate(s *State) (error, error) { 15 | if s.getEnvByName(e.Name) == nil { 16 | return errors.New("Bad environment"), nil 17 | } 18 | 19 | return nil, nil 20 | } 21 | 22 | func (e *EnvironmentDeleted) Update(s *State, t time.Time) *State { 23 | state := *s 24 | state.Environments = []Environment{} 25 | 26 | for _, env := range s.Environments { 27 | if env.Name != e.Name { 28 | state.Environments = append(state.Environments, env) 29 | } 30 | } 31 | 32 | for _, feature := range s.Features { 33 | delete(state.Enabled, EnvFeature{e.Name, feature.Name}) 34 | } 35 | 36 | return &state 37 | } 38 | 39 | func (e *EnvironmentDeleted) PrePersist(*State) (Event, error) { 40 | return e, nil 41 | } 42 | 43 | func (*EnvironmentDeleted) Notify(*State, notifier.Notifier) { 44 | } 45 | -------------------------------------------------------------------------------- /dashboard/containers/FeatureCreate.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { shape, func } from 'prop-types' 3 | import { withRouter } from 'react-router-dom' 4 | import FeatureForm from '../components/FeatureForm' 5 | import { createFeature } from '../utils/api' 6 | 7 | class FeatureCreate extends Component { 8 | constructor(props) { 9 | super(props) 10 | 11 | this.state = { 12 | errorText: null 13 | } 14 | 15 | this.handleSubmit = this.handleSubmit.bind(this) 16 | } 17 | 18 | handleSubmit(feature) { 19 | createFeature(feature) 20 | .then(() => this.props.history.push('/')) 21 | .catch(err => this.setState({ errorText: err.message })) 22 | } 23 | 24 | render() { 25 | return ( 26 | 32 | ) 33 | } 34 | } 35 | 36 | FeatureCreate.propTypes = { 37 | history: shape({ 38 | push: func 39 | }).isRequired 40 | } 41 | 42 | export default withRouter(FeatureCreate) 43 | -------------------------------------------------------------------------------- /dashboard/components/Toggle.css: -------------------------------------------------------------------------------- 1 | .lk-toggle { 2 | border-radius: 28px; 3 | display: inline-block; 4 | height: 22px; 5 | position: relative; 6 | width: 48px; 7 | } 8 | 9 | .lk-toggle input { 10 | display: none; 11 | } 12 | 13 | .lk-toggle span { 14 | background-color: #c9c0d1; 15 | border-radius: 28px; 16 | bottom: 0; 17 | box-shadow: inset 0 2px 0 rgba(0,0,0,.04); 18 | cursor: pointer; 19 | left: 0; 20 | position: absolute; 21 | right: 0; 22 | top: 0; 23 | transition: .2s; 24 | -webkit-transition: .2s; 25 | } 26 | 27 | .lk-toggle span:before { 28 | background-color: white; 29 | border-radius: 28px; 30 | bottom: 3px; 31 | content: ""; 32 | height: 16px; 33 | left: 3px; 34 | position: absolute; 35 | transition: .2s; 36 | -webkit-transition: .2s; 37 | width: 16px; 38 | } 39 | 40 | .lk-toggle input:checked + span { 41 | background-color: #6c5fc7; 42 | } 43 | 44 | .lk-toggle input:focus + span { 45 | box-shadow: 0 0 1px #6c5fc7; 46 | } 47 | 48 | .lk-toggle input:checked + span:before { 49 | -webkit-transform: translateX(26px); 50 | -ms-transform: translateX(26px); 51 | transform: translateX(26px); 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 MEDIGO GmbH http://www.medigo.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /dashboard/containers/EnvironmentCreate.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { shape, func } from 'prop-types' 3 | import { withRouter } from 'react-router-dom' 4 | import EnvironmentForm from '../components/EnvironmentForm' 5 | import { createEnvironment } from '../utils/api' 6 | 7 | class EnvironmentCreate extends Component { 8 | constructor(props) { 9 | super(props) 10 | 11 | this.state = { 12 | errorText: null 13 | } 14 | 15 | this.handleSubmit = this.handleSubmit.bind(this) 16 | } 17 | 18 | handleSubmit(env) { 19 | createEnvironment(env) 20 | .then(() => this.props.history.push('/')) 21 | .catch(err => this.setState({ errorText: err.message })) 22 | } 23 | 24 | render() { 25 | return ( 26 | 32 | ) 33 | } 34 | } 35 | 36 | EnvironmentCreate.propTypes = { 37 | history: shape({ 38 | push: func 39 | }).isRequired 40 | } 41 | 42 | export default withRouter(EnvironmentCreate) 43 | -------------------------------------------------------------------------------- /notifier/slack_notifier.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/vsco/slackhook" 7 | ) 8 | 9 | // SlackNotifier is a notifier that send messages to Slack. 10 | type SlackNotifier struct { 11 | client *slackhook.Client 12 | } 13 | 14 | // NewSlackNotifier creates a new SlackNotifier. 15 | func NewSlackNotifier(url string) Notifier { 16 | return &SlackNotifier{slackhook.New(url)} 17 | } 18 | 19 | // NotifyStatusChange notifies a change in the status of a flag. 20 | func (n *SlackNotifier) NotifyStatusChange(feature string, status bool, environment string) error { 21 | text := fmt.Sprintf("Feature *%s* is now %s in *%s*.", feature, label(status), environment) 22 | 23 | return n.client.Send(&slackhook.Message{ 24 | Attachments: []*slackhook.Attachment{ 25 | &slackhook.Attachment{ 26 | Text: text, 27 | Color: color(status), 28 | MarkdownIn: []string{"text"}, 29 | }, 30 | }, 31 | }) 32 | } 33 | 34 | func color(status bool) string { 35 | if status { 36 | return "good" 37 | } 38 | return "danger" 39 | } 40 | 41 | func label(status bool) string { 42 | if status { 43 | return "enabled" 44 | } 45 | return "disabled" 46 | } 47 | -------------------------------------------------------------------------------- /dashboard/components/FeatureDetail.css: -------------------------------------------------------------------------------- 1 | .lk-feature-detail { 2 | width: 100%; 3 | } 4 | 5 | .lk-feature-detail__flex { 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | padding: 2vw; 10 | } 11 | 12 | @media screen and (orientation: landscape) and (min-width: 1024px) { 13 | .lk-feature-detail__flex { 14 | padding: 1vw; 15 | } 16 | } 17 | 18 | .lk-feature-detail__header h2 { 19 | font-size: 22px; 20 | margin-bottom: 0; 21 | } 22 | 23 | .lk-feature-detail__header div { 24 | color: #968ba0; 25 | font-size: 13px; 26 | margin-right: 10px; 27 | } 28 | 29 | .lk-feature-detail__environment-name span { 30 | color: #968ba0; 31 | } 32 | 33 | .lk-feature-detail__environment-control { 34 | align-items: center; 35 | display: flex; 36 | font-size: 0.9rem; 37 | justify-content: space-between; 38 | } 39 | 40 | @media screen and (max-width: 768px) { 41 | .lk-feature-detail__environment-control { 42 | flex-flow: column nowrap; 43 | align-items: flex-end; 44 | } 45 | 46 | .lk-feature-detail__environment-control * + * > span { 47 | margin-left: 0; 48 | } 49 | } 50 | 51 | .lk-feature-detail__environment-control > * + * { 52 | margin-left: 1rem; 53 | } 54 | -------------------------------------------------------------------------------- /models/feature_toggled.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | log "github.com/Sirupsen/logrus" 9 | ) 10 | 11 | type FeatureToggled struct { 12 | Feature string `json:"feature"` 13 | Environment string `json:"environment"` 14 | Status bool `json:"status"` 15 | } 16 | 17 | func (e *FeatureToggled) Validate(s *State) (error, error) { 18 | if s.getEnvByName(e.Environment) == nil { 19 | return errors.New("Bad environment"), nil 20 | } 21 | 22 | if s.getFeatureByName(e.Feature) == nil { 23 | return errors.New("Bad Feature"), nil 24 | } 25 | 26 | return nil, nil 27 | } 28 | 29 | func (e *FeatureToggled) Update(s *State, t time.Time) *State { 30 | state := *s 31 | state.Enabled[EnvFeature{e.Environment, e.Feature}] = Status{ 32 | Enabled: e.Status, 33 | ToggledAt: &t, 34 | } 35 | return &state 36 | } 37 | 38 | func (e *FeatureToggled) Notify(s *State, n notifier.Notifier) { 39 | go func() { 40 | if err := n.NotifyStatusChange(e.Feature, e.Status, e.Environment); err != nil { 41 | log.Error("failed to notify feature status change: ", err) 42 | } 43 | }() 44 | } 45 | 46 | func (e *FeatureToggled) PrePersist(*State) (Event, error) { 47 | return e, nil 48 | } 49 | -------------------------------------------------------------------------------- /dashboard/components/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { bool, func, string } from 'prop-types' 3 | 4 | import './Input.css' 5 | 6 | const Input = ({ 7 | label, 8 | name, 9 | type, 10 | required, 11 | value, 12 | error, 13 | onChange, 14 | placeholder, 15 | autoFocus 16 | }) => 17 |
18 | 22 | onChange(name, e.target.value)} 27 | required={required} 28 | type={type} 29 | placeholder={placeholder} 30 | autoFocus={autoFocus} 31 | /> 32 | {error ?
{error}
: null} 33 |
34 | 35 | Input.propTypes = { 36 | label: string.isRequired, 37 | name: string.isRequired, 38 | value: string.isRequired, 39 | required: bool, 40 | error: string, 41 | onChange: func.isRequired, 42 | type: string, 43 | placeholder: string, 44 | autoFocus: bool 45 | } 46 | 47 | Input.defaultProps = { 48 | required: false, 49 | error: '', 50 | value: '', 51 | type: '', 52 | placeholder: '', 53 | autoFocus: false, 54 | onChange: () => {} 55 | } 56 | 57 | export default Input 58 | -------------------------------------------------------------------------------- /models/events.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | ) 9 | 10 | type Event interface { 11 | // Validate validates the event data against given (immutable) state. 12 | Validate(*State) (error, error) 13 | // PrePersist can return a modified event just before persisting. 14 | PrePersist(*State) (Event, error) 15 | // Update returns the new state with the event's effect applied. 16 | Update(*State, time.Time) *State 17 | // Notify can call a notifier about the event. 18 | Notify(*State, notifier.Notifier) 19 | } 20 | 21 | var types = map[string](func() Event){ 22 | "environment_created": func() Event { return &EnvironmentCreated{} }, 23 | "environment_deleted": func() Event { return &EnvironmentDeleted{} }, 24 | "environments_ordered": func() Event { return &EnvironmentsOrdered{} }, 25 | "feature_created": func() Event { return &FeatureCreated{} }, 26 | "feature_toggled": func() Event { return &FeatureToggled{} }, 27 | "feature_deleted": func() Event { return &FeatureDeleted{} }, 28 | "user_created": func() Event { return &UserCreated{} }, 29 | } 30 | 31 | func EventForType(eventType string) (Event, error) { 32 | f, ok := types[eventType] 33 | if !ok { 34 | return nil, errors.New("unknown event type") 35 | } 36 | 37 | return f(), nil 38 | } 39 | -------------------------------------------------------------------------------- /dashboard/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | BrowserRouter as Router, 4 | Redirect, 5 | Route, 6 | Switch 7 | } from 'react-router-dom' 8 | 9 | import Container from '../components/Container' 10 | import Footer from '../components/Footer' 11 | import Header from '../components/Header' 12 | import Main from '../components/Main' 13 | 14 | import EnvironmentCreate from './EnvironmentCreate' 15 | import FeatureCreate from './FeatureCreate' 16 | import FeatureDetail from './FeatureDetail' 17 | import Home from './Home' 18 | 19 | const App = () => { 20 | return ( 21 | 22 |
23 |
24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 36 | 37 | 38 | 39 |
40 | 41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | export default App 48 | -------------------------------------------------------------------------------- /models/environment_deleted_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestEnvironmentDeleted(t *testing.T) { 11 | var time time.Time 12 | s := NewState() 13 | 14 | s = requireValid(t, s, &EnvironmentCreated{Name: "e1"}).Update(s, time) 15 | s = requireValid(t, s, &EnvironmentCreated{Name: "e2"}).Update(s, time) 16 | s = requireValid(t, s, &FeatureCreated{Name: "f1"}).Update(s, time) 17 | s = requireValid(t, s, &FeatureCreated{Name: "f2"}).Update(s, time) 18 | s = requireValid(t, s, &FeatureCreated{Name: "f3"}).Update(s, time) 19 | s = requireValid(t, s, &FeatureToggled{Environment: "e1", Feature: "f2", Status: true}).Update(s, time) 20 | 21 | // successful deletion 22 | s = requireValid(t, s, &EnvironmentDeleted{Name: "e1"}).Update(s, time) 23 | 24 | require.Len(t, s.Environments, 1) 25 | require.Equal(t, "e2", s.Environments[0].Name) 26 | 27 | _, ok := s.Enabled[EnvFeature{Env: "e1", Feature: "f2"}] 28 | require.False(t, ok) 29 | 30 | // non-existing feature is invalid 31 | fd := &EnvironmentDeleted{Name: "e1"} 32 | valErr, err := fd.Validate(s) 33 | require.NoError(t, err) 34 | require.Error(t, valErr) 35 | 36 | // non-existing environment should not cause harm 37 | before := *s 38 | s = fd.Update(s, time) 39 | require.Equal(t, before, *s) 40 | } 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MEDIGO/laika 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/DataDog/datadog-go v0.0.0-20180305115502-72108a55170c 7 | github.com/MEDIGO/go-healthz v0.0.0-20160923151312-9b0725fef657 8 | github.com/Sirupsen/logrus v1.0.6-0.20180315010703-90150a8ed11b 9 | github.com/davecgh/go-spew v1.1.1 10 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 11 | github.com/go-sql-driver/mysql v1.3.1-0.20180308100310-1a676ac6e4dc 12 | github.com/google/uuid v1.1.1 13 | github.com/labstack/echo v3.3.4+incompatible 14 | github.com/labstack/gommon v0.2.2-0.20180316174944-3bc2d333a4c3 15 | github.com/mattn/go-colorable v0.1.0 16 | github.com/mattn/go-isatty v0.0.4 17 | github.com/pmezard/go-difflib v1.0.0 18 | github.com/rubenv/sql-migrate v0.0.0-20180217203553-081fe17d19ff 19 | github.com/stretchr/testify v1.2.2-0.20180319223459-c679ae2cc0cb 20 | github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2 21 | github.com/valyala/bytebufferpool v1.0.0 22 | github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 23 | github.com/vsco/slackhook v0.0.0-20160714202444-761b10b6951a 24 | golang.org/x/crypto v0.0.0-20180322175230-88942b9c40a4 25 | golang.org/x/sys v0.0.0-20180322165403-91ee8cde4354 26 | google.golang.org/appengine v1.0.1-0.20171212223047-5bee14b453b4 27 | gopkg.in/gorp.v1 v1.7.1 28 | gopkg.in/tylerb/graceful.v1 v1.2.15 29 | ) 30 | -------------------------------------------------------------------------------- /models/feature_deleted_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestFeatureDeleted(t *testing.T) { 11 | var time time.Time 12 | s := NewState() 13 | 14 | s = requireValid(t, s, &EnvironmentCreated{Name: "e1"}).Update(s, time) 15 | s = requireValid(t, s, &EnvironmentCreated{Name: "e2"}).Update(s, time) 16 | s = requireValid(t, s, &FeatureCreated{Name: "f1"}).Update(s, time) 17 | s = requireValid(t, s, &FeatureCreated{Name: "f2"}).Update(s, time) 18 | s = requireValid(t, s, &FeatureCreated{Name: "f3"}).Update(s, time) 19 | s = requireValid(t, s, &FeatureToggled{Environment: "e1", Feature: "f2", Status: true}).Update(s, time) 20 | 21 | // successful deletion 22 | s = requireValid(t, s, &FeatureDeleted{Name: "f2"}).Update(s, time) 23 | 24 | require.Len(t, s.Features, 2) 25 | require.Equal(t, "f1", s.Features[0].Name) 26 | require.Equal(t, "f3", s.Features[1].Name) 27 | 28 | _, ok := s.Enabled[EnvFeature{Env: "e1", Feature: "f2"}] 29 | require.False(t, ok) 30 | 31 | // non-existing feature is invalid 32 | fd := &FeatureDeleted{Name: "f2"} 33 | valErr, err := fd.Validate(s) 34 | require.NoError(t, err) 35 | require.Error(t, valErr) 36 | 37 | // non-existing feature should not cause harm 38 | before := *s 39 | s = fd.Update(s, time) 40 | require.Equal(t, before, *s) 41 | } 42 | -------------------------------------------------------------------------------- /dashboard/components/EnvironmentForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { string, func } from 'prop-types' 3 | 4 | import Form from './Form' 5 | import Input from './Input' 6 | import Card from './Card' 7 | 8 | export default class FeatureForm extends Component { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.state = {} 13 | 14 | this.handleChange = this.handleChange.bind(this) 15 | this.handleSubmit = this.handleSubmit.bind(this) 16 | } 17 | 18 | handleChange(name, value) { 19 | this.setState({ [name]: value }) 20 | } 21 | 22 | handleSubmit() { 23 | this.props.onSubmit(this.state) 24 | } 25 | 26 | render() { 27 | return ( 28 | 29 |
34 | 43 |
44 |
45 | ) 46 | } 47 | } 48 | 49 | FeatureForm.propTypes = { 50 | onSubmit: func.isRequired, 51 | submitText: string, 52 | titleText: string.isRequired, 53 | errorText: string 54 | } 55 | 56 | FeatureForm.defaultProps = { 57 | submitText: null, 58 | errorText: null 59 | } 60 | -------------------------------------------------------------------------------- /dashboard/components/FeatureForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { func, string } from 'prop-types' 3 | 4 | import Form from './Form' 5 | import Input from './Input' 6 | import Card from './Card' 7 | 8 | export default class FeatureForm extends Component { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.state = {} 13 | 14 | this.handleChange = this.handleChange.bind(this) 15 | this.handleSubmit = this.handleSubmit.bind(this) 16 | } 17 | 18 | handleChange(name, value) { 19 | this.setState({ [name]: value }) 20 | } 21 | 22 | handleSubmit() { 23 | this.props.onSubmit(this.state) 24 | } 25 | 26 | render() { 27 | return ( 28 | 29 |
34 | 43 |
44 |
45 | ) 46 | } 47 | } 48 | 49 | FeatureForm.propTypes = { 50 | onSubmit: func.isRequired, 51 | submitText: string, 52 | titleText: string.isRequired, 53 | errorText: string 54 | } 55 | 56 | FeatureForm.defaultProps = { 57 | submitText: null, 58 | errorText: null 59 | } 60 | -------------------------------------------------------------------------------- /models/environments_ordered.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | ) 9 | 10 | type EnvironmentsOrdered struct { 11 | Order []string `json:"order"` 12 | } 13 | 14 | func (e *EnvironmentsOrdered) Validate(s *State) (error, error) { 15 | err := errors.New("Order must contain every environment exactly once") 16 | 17 | if len(e.Order) != len(s.Environments) { 18 | return err, nil 19 | } 20 | 21 | envs := map[string]bool{} 22 | for _, env := range e.Order { 23 | if _, ok := envs[env]; ok { 24 | return err, nil 25 | } 26 | 27 | if s.getEnvByName(env) == nil { 28 | return err, nil 29 | } 30 | envs[env] = true 31 | } 32 | 33 | return nil, nil 34 | } 35 | 36 | func (e *EnvironmentsOrdered) Update(s *State, t time.Time) *State { 37 | state := *s 38 | state.Environments = s.Environments[:] 39 | 40 | positions := map[string]int{} 41 | for i, name := range e.Order { 42 | positions[name] = i 43 | } 44 | 45 | for src, env := range state.Environments { 46 | dest, ok := positions[env.Name] 47 | if !ok { 48 | continue 49 | } 50 | 51 | if dest >= len(state.Environments) { 52 | continue 53 | } 54 | 55 | state.Environments[dest], state.Environments[src] = state.Environments[src], state.Environments[dest] 56 | } 57 | 58 | return &state 59 | } 60 | 61 | func (e *EnvironmentsOrdered) PrePersist(*State) (Event, error) { 62 | return e, nil 63 | } 64 | 65 | func (*EnvironmentsOrdered) Notify(*State, notifier.Notifier) { 66 | } 67 | -------------------------------------------------------------------------------- /dashboard/utils/api.js: -------------------------------------------------------------------------------- 1 | const request = (method, endpoint, payload) => { 2 | const opts = { 3 | headers: {}, 4 | credentials: 'same-origin', 5 | method 6 | } 7 | 8 | if (payload) { 9 | opts.headers['Content-Type'] = 'application/json' 10 | opts.body = JSON.stringify(payload) 11 | } 12 | 13 | return fetch(endpoint, opts).then(res => { 14 | if (!res.ok) { 15 | return res.json().then(err => { 16 | throw new Error(err.message) 17 | }) 18 | } 19 | return res.json() 20 | }) 21 | } 22 | 23 | const get = (endpoint) => 24 | request('GET', endpoint) 25 | 26 | const post = (endpoint, payload) => 27 | request('POST', endpoint, payload) 28 | 29 | const listFeatures = () => 30 | get('/api/features') 31 | 32 | const createFeature = (feature) => 33 | post('/api/events/feature_created', { 34 | name: feature.name 35 | }) 36 | 37 | const getFeature = (name) => 38 | get(`/api/features/${window.encodeURIComponent(name)}`) 39 | 40 | const listEnvironments = () => 41 | get('/api/environments') 42 | 43 | const createEnvironment = (environment) => 44 | post('/api/events/environment_created', { 45 | name: environment.name 46 | }) 47 | 48 | const toggleFeature = (environment, feature, status) => 49 | post('/api/events/feature_toggled', { 50 | environment: environment, 51 | feature: feature, 52 | status: status 53 | }) 54 | 55 | const deleteFeature = (name) => 56 | post('/api/events/feature_deleted', { name }) 57 | 58 | export { listFeatures, createFeature, getFeature, listEnvironments, createEnvironment, toggleFeature, deleteFeature } 59 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## ?.?.? 4 | 5 | ### New features 6 | 7 | * Environments can be deleted through the API. 8 | 9 | ### Bugfixes and minor changes 10 | 11 | * Slack notifier is now less spammy. 12 | * Fix unstable UI sort edge case with old data. 13 | 14 | 15 | ## 1.0.0 16 | 17 | Major changes on frontend and backend. The API contains breaking changes, but neither the Go nor the PHP client are affected by these changes. 18 | 19 | ### Major changes 20 | 21 | * Switched frontend to React. 22 | * Switched backend to an event-sourcing architecture (migration is automatic). 23 | * API no longer returns numeric IDs, but uses the name of environments and features for identity. 24 | 25 | ### New features 26 | 27 | * Features can be deleted from the UI. 28 | * Always migrate database on startup. 29 | * Increased creation timestamp precision from days to seconds. 30 | * Order environments by creation time in the UI. 31 | * Add API endpoint to order environments manually. 32 | * Auto focus input fields in the UI. 33 | 34 | ### Bugfixes and minor changes 35 | 36 | * Unknown API routes now properly return 404. 37 | * Unknown frontend routes now redirect to the home page. 38 | 39 | 40 | ## 0.8.0 41 | 42 | This is the first versioned release. 43 | 44 | ### New features 45 | 46 | * Features are now sorted in reverse chronological order. 47 | 48 | ### Bugfixes and minor changes 49 | 50 | * Names with spaces, slashes, and various other characters no longer break the UI. 51 | * Fix typescript error with moment.js. 52 | 53 | 54 | ## 0.0.0 55 | 56 | This corresponds to everything up to commit `3637a6e` (inclusive) made on Jan 29, 2018. 57 | -------------------------------------------------------------------------------- /api/event_resource.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/DataDog/datadog-go/statsd" 7 | "github.com/MEDIGO/laika/models" 8 | "github.com/MEDIGO/laika/notifier" 9 | "github.com/MEDIGO/laika/store" 10 | "github.com/labstack/echo" 11 | ) 12 | 13 | type EventResource struct { 14 | store store.Store 15 | stats *statsd.Client 16 | notifier notifier.Notifier 17 | } 18 | 19 | func NewEventResource(store store.Store, stats *statsd.Client, notifier notifier.Notifier) *EventResource { 20 | return &EventResource{store, stats, notifier} 21 | } 22 | 23 | func (r *EventResource) Create(c echo.Context) error { 24 | eventType := c.Param("type") 25 | event, err := models.EventForType(eventType) 26 | if err != nil { 27 | return Invalid(c, err.Error()) 28 | } 29 | 30 | if err := c.Bind(&event); err != nil { 31 | return BadRequest(c, "Body must be a valid JSON object") 32 | } 33 | 34 | state := getState(c) 35 | 36 | valErr, err := event.Validate(state) 37 | if err != nil { 38 | return InternalServerError(c, err) 39 | } else if valErr != nil { 40 | return BadRequest(c, valErr.Error()) 41 | } 42 | 43 | event, err = event.PrePersist(state) 44 | if err != nil { 45 | return InternalServerError(c, err) 46 | } 47 | 48 | cleanEvent, err := json.Marshal(event) 49 | if err != nil { 50 | return InternalServerError(c, err) 51 | } 52 | 53 | id, err := r.store.Persist(eventType, string(cleanEvent)) 54 | if err != nil { 55 | return InternalServerError(c, err) 56 | } 57 | 58 | event.Notify(state, r.notifier) 59 | 60 | return Created(c, struct { 61 | ID int64 `json:"id"` 62 | }{ID: id}) 63 | } 64 | -------------------------------------------------------------------------------- /models/user_created.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/notifier" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | type UserCreated struct { 12 | Username string `json:"username"` 13 | Password *string `json:"password,omitempty"` 14 | PasswordHash string `json:"password_hash"` 15 | } 16 | 17 | func (e *UserCreated) Validate(s *State) (error, error) { 18 | if e.Username == "" { 19 | return errors.New("Username must not be empty"), nil 20 | } 21 | 22 | if s.getUserByName(e.Username) != nil { 23 | return errors.New("Name is already in use"), nil 24 | } 25 | 26 | if e.Password == nil && e.PasswordHash == "" || 27 | e.Password != nil && e.PasswordHash != "" { 28 | return errors.New("Exactly one of either password or password hash is required"), nil 29 | } 30 | 31 | return nil, nil 32 | } 33 | 34 | func (e *UserCreated) Update(s *State, t time.Time) *State { 35 | state := *s 36 | 37 | state.Users = append(state.Users, User{ 38 | Username: e.Username, 39 | PasswordHash: e.PasswordHash, 40 | }) 41 | 42 | return &state 43 | } 44 | 45 | func (e *UserCreated) PrePersist(s *State) (Event, error) { 46 | if e.Password == nil { 47 | // already hashed 48 | return e, nil 49 | } 50 | 51 | event := *e 52 | 53 | hash, err := bcrypt.GenerateFromPassword([]byte(*e.Password), bcrypt.DefaultCost) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | // discard plain text 59 | event.Password = nil 60 | event.PasswordHash = string(hash) 61 | 62 | return &event, nil 63 | } 64 | 65 | func (e *UserCreated) Notify(*State, notifier.Notifier) { 66 | } 67 | -------------------------------------------------------------------------------- /models/environments_ordered_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestEnvironmentsOrdered(t *testing.T) { 11 | var time time.Time 12 | s := NewState() 13 | 14 | s = requireValid(t, s, &EnvironmentCreated{Name: "e1"}).Update(s, time) 15 | s = requireValid(t, s, &EnvironmentCreated{Name: "e2"}).Update(s, time) 16 | 17 | // successful reordering 18 | s = requireValid(t, s, &EnvironmentsOrdered{Order: []string{"e2", "e1"}}).Update(s, time) 19 | 20 | require.Len(t, s.Environments, 2) 21 | require.Equal(t, "e2", s.Environments[0].Name) 22 | require.Equal(t, "e1", s.Environments[1].Name) 23 | 24 | // various errors 25 | requireInvalid(t, s, &EnvironmentsOrdered{Order: []string{"e1"}}) 26 | requireInvalid(t, s, &EnvironmentsOrdered{Order: []string{"e1", "e2", "e3"}}) 27 | requireInvalid(t, s, &EnvironmentsOrdered{Order: []string{"e1", "e1"}}) 28 | 29 | // check graceful error handling 30 | s = (&EnvironmentsOrdered{Order: []string{"e3"}}).Update(s, time) 31 | require.Len(t, s.Environments, 2) 32 | require.Equal(t, "e2", s.Environments[0].Name) 33 | require.Equal(t, "e1", s.Environments[1].Name) 34 | 35 | s = (&EnvironmentsOrdered{Order: []string{"e1", "e1", "e2"}}).Update(s, time) 36 | require.Len(t, s.Environments, 2) 37 | require.Equal(t, "e2", s.Environments[0].Name) 38 | require.Equal(t, "e1", s.Environments[1].Name) 39 | 40 | s = (&EnvironmentsOrdered{Order: []string{"e1"}}).Update(s, time) 41 | require.Len(t, s.Environments, 2) 42 | require.Equal(t, "e1", s.Environments[0].Name) 43 | require.Equal(t, "e2", s.Environments[1].Name) 44 | } 45 | -------------------------------------------------------------------------------- /models/state.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type User struct { 8 | Username string `json:"name"` 9 | PasswordHash string `json:"password_hash"` 10 | CreatedAt time.Time `json:"created_at"` 11 | } 12 | 13 | type Environment struct { 14 | Name string `json:"name"` 15 | CreatedAt time.Time `json:"created_at"` 16 | } 17 | 18 | type Feature struct { 19 | Name string `json:"name"` 20 | CreatedAt time.Time `json:"created_at"` 21 | } 22 | 23 | type EnvFeature struct { 24 | Env string 25 | Feature string 26 | } 27 | 28 | type Status struct { 29 | Enabled bool 30 | ToggledAt *time.Time 31 | } 32 | 33 | type State struct { 34 | Users []User 35 | Environments []Environment 36 | Features []Feature 37 | Enabled map[EnvFeature]Status 38 | } 39 | 40 | func NewState() *State { 41 | return &State{ 42 | Environments: []Environment{}, 43 | Features: []Feature{}, 44 | Users: []User{}, 45 | Enabled: map[EnvFeature]Status{}, 46 | } 47 | 48 | } 49 | 50 | func (s *State) getFeatureByName(name string) *Feature { 51 | for _, feature := range s.Features { 52 | if feature.Name == name { 53 | return &feature 54 | } 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (s *State) getEnvByName(name string) *Environment { 61 | for _, env := range s.Environments { 62 | if env.Name == name { 63 | return &env 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (s *State) getUserByName(username string) *User { 71 | for _, user := range s.Users { 72 | if user.Username == username { 73 | return &user 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /client/feature_cache_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | "github.com/stretchr/testify/require" 6 | ) 7 | 8 | func TestFeatureCacheAdd(t *testing.T) { 9 | cache := NewFeatureCache() 10 | 11 | feature := &Feature{ 12 | Name: "awesome_feature", 13 | } 14 | 15 | cache.Add(feature) 16 | 17 | found := cache.Get("awesome_feature") 18 | require.NotNil(t, found) 19 | require.Equal(t, "awesome_feature", found.Name) 20 | } 21 | 22 | func TestFeatureCacheAddAll(t *testing.T) { 23 | cache := NewFeatureCache() 24 | 25 | features := []*Feature{ 26 | &Feature{ 27 | Name: "awesome_feature_1", 28 | }, 29 | &Feature{ 30 | Name: "awesome_feature_2", 31 | }, 32 | } 33 | 34 | cache.AddAll(features) 35 | 36 | found := cache.Get("awesome_feature_1") 37 | require.NotNil(t, found) 38 | require.Equal(t, "awesome_feature_1", found.Name) 39 | 40 | found = cache.Get("awesome_feature_2") 41 | require.NotNil(t, found) 42 | require.Equal(t, "awesome_feature_2", found.Name) 43 | } 44 | 45 | func TestFeatureCacheGetAll(t *testing.T) { 46 | cache := NewFeatureCache() 47 | 48 | features := []*Feature{ 49 | &Feature{ 50 | Name: "awesome_feature_1", 51 | }, 52 | &Feature{ 53 | Name: "awesome_feature_2", 54 | }, 55 | } 56 | 57 | cache.AddAll(features) 58 | 59 | pulledFeatures := cache.GetAll() 60 | 61 | require.Equal(t, len(features), len(pulledFeatures)) 62 | require.Equal(t, features[0], pulledFeatures[features[0].Name]) 63 | require.Equal(t, features[1], pulledFeatures[features[1].Name]) 64 | // no strict equality -- pointer values should be different 65 | require.Equal(t, features[0] == pulledFeatures[features[0].Name], false) 66 | } -------------------------------------------------------------------------------- /store/schema/1_init.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE IF NOT EXISTS `feature`( 4 | `id` INT NOT NULL AUTO_INCREMENT, 5 | `name` VARCHAR(255) NOT NULL, 6 | `created_at` DATE NOT NULL, 7 | 8 | PRIMARY KEY (id), 9 | UNIQUE (name) 10 | ); 11 | 12 | CREATE TABLE IF NOT EXISTS `environment` ( 13 | `id` INT NOT NULL AUTO_INCREMENT, 14 | `name` VARCHAR(255) NOT NULL, 15 | `created_at` DATE NOT NULL, 16 | 17 | PRIMARY KEY (id), 18 | UNIQUE (name) 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS `feature_status` ( 22 | `id` INT NOT NULL AUTO_INCREMENT, 23 | `feature_id` INT NOT NULL, 24 | `environment_id` INT NOT NULL, 25 | `enabled` BOOLEAN NOT NULL, 26 | `created_at` DATE NOT NULL, 27 | 28 | PRIMARY KEY (id), 29 | FOREIGN KEY (feature_id) REFERENCES feature(id), 30 | FOREIGN KEY (environment_id) REFERENCES environment(id) 31 | ); 32 | 33 | CREATE TABLE IF NOT EXISTS `feature_status_history` ( 34 | `id` INT NOT NULL AUTO_INCREMENT, 35 | `feature_id` INT NOT NULL, 36 | `environment_id` INT NOT NULL, 37 | `feature_status_id` INT NOT NULL, 38 | `enabled` BOOLEAN NOT NULL, 39 | `created_at` DATE NOT NULL, 40 | `timestamp` DATE NOT NULL, 41 | 42 | PRIMARY KEY (id), 43 | FOREIGN KEY (feature_id) REFERENCES feature(id), 44 | FOREIGN KEY (environment_id) REFERENCES environment(id), 45 | FOREIGN KEY (feature_status_id) REFERENCES feature_status(id) 46 | ); 47 | 48 | -- +migrate Down 49 | 50 | DROP TABLE IF EXISTS feature_status_history; 51 | DROP TABLE IF EXISTS feature_status; 52 | DROP TABLE IF EXISTS environment; 53 | DROP TABLE IF EXISTS feature; 54 | -------------------------------------------------------------------------------- /client/feature_cache.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "sync" 5 | "bytes" 6 | "encoding/gob" 7 | ) 8 | 9 | // FeatureCache is a in-memory threadsafe cache for Features. 10 | type FeatureCache struct { 11 | features map[string]*Feature 12 | lock sync.RWMutex 13 | } 14 | 15 | // NewFeatureCache creates a new FeatureCache. 16 | func NewFeatureCache() *FeatureCache { 17 | return &FeatureCache{ 18 | features: make(map[string]*Feature), 19 | } 20 | } 21 | 22 | // Add adds a feature to the cache. 23 | func (fc *FeatureCache) Add(feature *Feature) { 24 | fc.lock.Lock() 25 | defer fc.lock.Unlock() 26 | 27 | fc.features[feature.Name] = feature 28 | } 29 | 30 | // AddAll adds a list of features to the cache. 31 | func (fc *FeatureCache) AddAll(features []*Feature) { 32 | fc.lock.Lock() 33 | defer fc.lock.Unlock() 34 | 35 | for _, feature := range features { 36 | fc.features[feature.Name] = feature 37 | } 38 | } 39 | 40 | // Get gets a Feature from the cache if it exits. 41 | func (fc *FeatureCache) Get(name string) *Feature { 42 | fc.lock.RLock() 43 | defer fc.lock.RUnlock() 44 | 45 | feature, ok := fc.features[name] 46 | if !ok { 47 | return nil 48 | } 49 | 50 | return feature 51 | } 52 | 53 | func (fc *FeatureCache) GetAll() map[string]*Feature { 54 | fc.lock.RLock() 55 | defer fc.lock.RUnlock() 56 | 57 | // deep clone features map 58 | var buf bytes.Buffer 59 | enc := gob.NewEncoder(&buf) 60 | dec := gob.NewDecoder(&buf) 61 | 62 | err := enc.Encode(fc.features) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | var deepCopy map[string]*Feature 68 | err = dec.Decode(&deepCopy) 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | return deepCopy 74 | } -------------------------------------------------------------------------------- /dashboard/components/FeatureList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { arrayOf, shape, string } from 'prop-types' 3 | import { Link } from 'react-router-dom' 4 | import moment from 'moment' 5 | 6 | import Section from './Section' 7 | import TagList from './TagList' 8 | 9 | import './FeatureList.css' 10 | 11 | const sort = (features) => features.sort((a, b) => { 12 | if (a.created_at < b.created_at) return 1 13 | if (a.created_at > b.created_at) return -1 14 | return b.name < a.name ? -1 : b.name > a.name 15 | }) 16 | 17 | const parseStatus = (environments, status) => 18 | environments.map(env => ({ 19 | name: env.name, 20 | enabled: status[env.name] 21 | })) 22 | 23 | const FeatureList = ({ environments, features }) => { 24 | const items = sort(features).map(feature => ( 25 |
26 | 27 |
28 | {feature.name} 29 | 30 |
31 |
32 | Created {moment(feature.created_at).fromNow()} 33 |
34 | 35 |
36 | )) 37 | 38 | return ( 39 |
40 |
{items}
41 |
42 | ) 43 | } 44 | 45 | FeatureList.propTypes = { 46 | features: arrayOf( 47 | shape({ 48 | name: string 49 | }) 50 | ).isRequired, 51 | environments: arrayOf( 52 | shape({ 53 | name: string 54 | }) 55 | ).isRequired 56 | } 57 | 58 | export default FeatureList 59 | -------------------------------------------------------------------------------- /dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laika", 3 | "version": "0.2.0", 4 | "description": "A feature flag service", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --display-modules --config webpack.prod.config.js --colors --progress", 8 | "watch": "webpack --config webpack.dev.config.js --colors --progress --watch", 9 | "prettier": "prettier-eslint 'components/**/*.js' 'containers/**/*.js' 'utils/**/*.js' --write" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/MEDIGO/laika.git" 14 | }, 15 | "author": "MEDIGO GmbH", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/MEDIGO/laika/issues" 19 | }, 20 | "homepage": "https://github.com/MEDIGO/laika", 21 | "devDependencies": { 22 | "babel-core": "^6.24.1", 23 | "babel-eslint": "^8.2.2", 24 | "babel-loader": "^7.0.0", 25 | "babel-preset-env": "^1.4.0", 26 | "babel-preset-es2015": "^6.24.1", 27 | "babel-preset-react": "^6.24.1", 28 | "css-loader": "^0.28.1", 29 | "eslint": "^4.18.1", 30 | "eslint-config-standard": "^11.0.0", 31 | "eslint-config-standard-react": "^6.0.0", 32 | "eslint-loader": "^1.9.0", 33 | "eslint-plugin-import": "^2.8.0", 34 | "eslint-plugin-jsx-a11y": "^6.0.3", 35 | "eslint-plugin-node": "^6.0.0", 36 | "eslint-plugin-promise": "^3.6.0", 37 | "eslint-plugin-react": "^7.7.0", 38 | "eslint-plugin-standard": "^3.0.1", 39 | "html-webpack-plugin": "^2.28.0", 40 | "prettier-eslint": "^8.8.1", 41 | "prettier-eslint-cli": "^4.7.1", 42 | "style-loader": "^0.17.0", 43 | "uglifyjs-webpack-plugin": "1.2.0", 44 | "webpack": "^2.4.1" 45 | }, 46 | "dependencies": { 47 | "history": "^4.6.3", 48 | "moment": "^2.18.1", 49 | "prop-types": "^15.5.8", 50 | "react": "^15.5.4", 51 | "react-dom": "^15.5.4", 52 | "react-router-dom": "^4.1.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /dashboard/containers/FeatureDetail.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import moment from 'moment' 3 | import { shape, object, func } from 'prop-types' 4 | import { withRouter } from 'react-router-dom' 5 | import FeatureDetailComponent from '../components/FeatureDetail' 6 | import { getFeature, toggleFeature, deleteFeature } from '../utils/api' 7 | 8 | class FeatureDetail extends Component { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.state = { 13 | loading: true, 14 | environments: [], 15 | feature: null 16 | } 17 | 18 | this.handleToggle = this.handleToggle.bind(this) 19 | this.handleDelete = this.handleDelete.bind(this) 20 | } 21 | 22 | componentDidMount() { 23 | getFeature(window.decodeURIComponent(this.props.match.params.name)).then( 24 | feature => 25 | this.setState({ 26 | loading: false, 27 | environments: feature.feature_status, 28 | feature 29 | }) 30 | ) 31 | } 32 | 33 | handleToggle(name, value) { 34 | const envs = this.state.environments.map(e => { 35 | if (e.name === name) { 36 | return Object.assign({}, e, { 37 | status: value, 38 | toggled_at: moment() 39 | }) 40 | } 41 | return e 42 | }) 43 | toggleFeature(name, this.state.feature.name, value).then(() => { 44 | this.setState({ 45 | environments: envs 46 | }) 47 | }) 48 | } 49 | 50 | handleDelete(name) { 51 | deleteFeature(name).then(() => this.props.history.push('/')) 52 | } 53 | 54 | render() { 55 | if (this.state.loading) return null 56 | return ( 57 | 63 | ) 64 | } 65 | } 66 | 67 | FeatureDetail.propTypes = { 68 | match: shape({ 69 | params: object 70 | }).isRequired, 71 | history: shape({ 72 | push: func 73 | }).isRequired 74 | } 75 | 76 | export default withRouter(FeatureDetail) 77 | -------------------------------------------------------------------------------- /api/response.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | log "github.com/Sirupsen/logrus" 7 | "github.com/labstack/echo" 8 | ) 9 | 10 | // Error represents an error message response. 11 | type Error struct { 12 | Message string `json:"message"` 13 | } 14 | 15 | // OK generates an HTTP 200 OK response with specified payload serialized as JSON. 16 | func OK(c echo.Context, payload interface{}) error { 17 | return c.JSON(http.StatusOK, payload) 18 | } 19 | 20 | // Created generates an HTTP 201 Created response with specified payload serialized as JSON. 21 | func Created(c echo.Context, payload interface{}) error { 22 | return c.JSON(http.StatusCreated, payload) 23 | } 24 | 25 | // NoContent generates an empty HTTP 204 No Conent response. 26 | func NoContent(c echo.Context) error { 27 | return c.NoContent(http.StatusNoContent) 28 | } 29 | 30 | // BadRequest generates an HTTP 400 Bad Request response with specified error message serialized as JSON. 31 | func BadRequest(c echo.Context, msg string) error { 32 | return c.JSON(http.StatusBadRequest, Error{msg}) 33 | } 34 | 35 | // NotFound generates an HTTP 404 Not Found response with a generic error message serialized as JSON. 36 | func NotFound(c echo.Context) error { 37 | return c.JSON(http.StatusNotFound, Error{"Resource not found"}) 38 | } 39 | 40 | // Conflict generates an HTTP 409 Conflict response with specified error message serialized as JSON. 41 | func Conflict(c echo.Context, msg string) error { 42 | return c.JSON(http.StatusConflict, Error{msg}) 43 | } 44 | 45 | // Invalid generates an HTTP 422 Unprocessable Entity response with specified error message serialized as JSON. 46 | func Invalid(c echo.Context, msg string) error { 47 | // this status is not in the net/http package 48 | return c.JSON(422, Error{msg}) 49 | } 50 | 51 | // InternalServerError generates an HTTP 500 Internal Server Error response with a generic error message 52 | // serialized as JSON, while the provided error is logged with ERROR level. 53 | func InternalServerError(c echo.Context, err error) error { 54 | log.WithFields(log.Fields{ 55 | "request_id": RequestID(c), 56 | }).Error(err) 57 | 58 | return c.JSON(http.StatusInternalServerError, Error{"Oops! Something went wrong"}) 59 | } 60 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/DataDog/datadog-go/statsd" 8 | "github.com/MEDIGO/go-healthz" 9 | "github.com/MEDIGO/laika/notifier" 10 | "github.com/MEDIGO/laika/store" 11 | "github.com/labstack/echo" 12 | "github.com/labstack/echo/middleware" 13 | ) 14 | 15 | // ServerConfig is used to parametrize a Server. 16 | type ServerConfig struct { 17 | RootUsername string 18 | RootPassword string 19 | Store store.Store 20 | Stats *statsd.Client 21 | Notifier notifier.Notifier 22 | } 23 | 24 | // NewServer creates a new server. 25 | func NewServer(conf ServerConfig) (*echo.Echo, error) { 26 | if conf.RootPassword == "" { 27 | return nil, errors.New("missing root username") 28 | } 29 | 30 | if conf.RootPassword == "" { 31 | return nil, errors.New("missing root password") 32 | } 33 | 34 | if conf.Store == nil { 35 | return nil, errors.New("missing store") 36 | } 37 | 38 | if conf.Notifier == nil { 39 | conf.Notifier = notifier.NewNOOPNotifier() 40 | } 41 | 42 | e := echo.New() 43 | 44 | basicAuthMiddleware := AuthMiddleware(conf.RootUsername, conf.RootPassword, conf.Store) 45 | 46 | e.Use(TraceMiddleware()) 47 | e.Use(LogMiddleware()) 48 | e.Use(InstrumentMiddleware(conf.Stats)) 49 | e.Use(middleware.Recover()) 50 | 51 | events := NewEventResource(conf.Store, conf.Stats, conf.Notifier) 52 | 53 | e.GET("/api/health", echo.WrapHandler(healthz.Handler())) 54 | 55 | e.Use(StateMiddleware(conf.Store)) 56 | 57 | publicApi := e.Group("") 58 | publicApi.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 59 | AllowMethods: []string{http.MethodGet}, 60 | })) 61 | 62 | privateApi := e.Group("/api", basicAuthMiddleware) 63 | 64 | // Public routes go here 65 | publicApi.GET("/api/features/:name/status/:env", GetFeatureStatus) 66 | 67 | // Private(behind auth) routes go here 68 | privateApi.POST("/events/:type", events.Create) 69 | privateApi.GET("/features/:name", GetFeature) 70 | privateApi.GET("/features", ListFeatures) 71 | privateApi.GET("/environments", ListEnvironments) 72 | privateApi.GET("/*", func(c echo.Context) error { return NotFound(c) }) 73 | 74 | e.Static("/assets", "dashboard/public/assets") 75 | e.File("/*", "dashboard/public/index.html") 76 | 77 | return e, nil 78 | } 79 | -------------------------------------------------------------------------------- /api/feature_resource.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/url" 5 | "time" 6 | 7 | "github.com/MEDIGO/laika/models" 8 | "github.com/labstack/echo" 9 | ) 10 | 11 | func GetFeature(c echo.Context) error { 12 | name, err := url.QueryUnescape(c.Param("name")) 13 | if err != nil { 14 | return BadRequest(c, "Bad feature name") 15 | } 16 | 17 | state := getState(c) 18 | for _, feature := range state.Features { 19 | if feature.Name == name { 20 | return OK(c, *getFeature(&feature, state)) 21 | } 22 | } 23 | 24 | return NotFound(c) 25 | } 26 | 27 | func ListFeatures(c echo.Context) error { 28 | state := getState(c) 29 | status := []featureResource{} 30 | for _, feature := range state.Features { 31 | status = append(status, *getFeature(&feature, state)) 32 | } 33 | return OK(c, status) 34 | } 35 | 36 | // Return a boolean value indicating the status of the requested feature 37 | func GetFeatureStatus(c echo.Context) error { 38 | name, err := url.QueryUnescape(c.Param("name")) 39 | if err != nil { 40 | return OK(c, false) 41 | } 42 | 43 | env, err := url.QueryUnescape(c.Param("env")) 44 | if err != nil { 45 | return OK(c, false) 46 | } 47 | 48 | state := getState(c) 49 | for _, feature := range state.Features { 50 | if feature.Name == name { 51 | feature_details := *getFeature(&feature, state) 52 | return OK(c, feature_details.Status[env]) 53 | } 54 | } 55 | return OK(c, false) 56 | } 57 | 58 | 59 | func getFeature(feature *models.Feature, s *models.State) *featureResource { 60 | f := featureResource{ 61 | Feature: *feature, 62 | Status: map[string]bool{}, 63 | FeatureStatuses: []featureStatus{}, 64 | } 65 | for _, env := range s.Environments { 66 | status, ok := s.Enabled[models.EnvFeature{ 67 | Env: env.Name, 68 | Feature: feature.Name, 69 | }] 70 | toggled := ok && status.Enabled 71 | f.Status[env.Name] = toggled 72 | f.FeatureStatuses = append(f.FeatureStatuses, featureStatus{ 73 | Name: env.Name, 74 | Status: toggled, 75 | ToggledAt: status.ToggledAt, 76 | }) 77 | } 78 | 79 | return &f 80 | } 81 | 82 | type featureResource struct { 83 | models.Feature 84 | Status map[string]bool `json:"status"` 85 | FeatureStatuses []featureStatus `json:"feature_status"` 86 | } 87 | 88 | type featureStatus struct { 89 | Name string `json:"name"` 90 | Status bool `json:"status"` 91 | ToggledAt *time.Time `json:"toggled_at,omitempty"` 92 | } 93 | -------------------------------------------------------------------------------- /dashboard/components/FeatureDetail.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { arrayOf, shape, string, func } from 'prop-types' 3 | import moment from 'moment' 4 | import Button from './Button' 5 | import Toggle from './Toggle' 6 | import Section from './Section' 7 | import './FeatureDetail.css' 8 | 9 | import { capitalize } from '../utils/string' 10 | 11 | export default class FeatureDetail extends Component { 12 | constructor(props) { 13 | super(props) 14 | 15 | this.state = { 16 | deleteUnlocked: false 17 | } 18 | 19 | this.lockDelete = this.lockDelete.bind(this) 20 | this.unlockDelete = this.unlockDelete.bind(this) 21 | } 22 | 23 | lockDelete() { 24 | this.setState({ deleteUnlocked: false }) 25 | } 26 | 27 | unlockDelete() { 28 | this.setState({ deleteUnlocked: true }) 29 | } 30 | 31 | render() { 32 | const { environments, feature, onToggle, onDelete } = this.props 33 | const { deleteUnlocked } = this.state 34 | 35 | const envStatus = environments.map(env => ( 36 |
37 | 38 | {capitalize(env.name)} ({env.name}) 39 | 40 | 41 | 42 | {env.toggled_at 43 | ? 'toggled ' + moment(env.toggled_at).fromNow() 44 | : 'never toggled'} 45 | 46 | 47 | 48 |
49 | )) 50 | 51 | const cancel = deleteUnlocked ? ( 52 |