├── .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 |
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 |
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 |
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 |
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 |
53 | ) : null
54 | const del = deleteUnlocked ? (
55 |