From 5e0e34eff7f45b2b9cd29fbb3c8505ea708c20a9 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 12 Sep 2024 18:00:25 +0300 Subject: [PATCH] added button with history of flag/value (#39) * added button with history of flag/value --------- Co-authored-by: d.maximchuk --- featureflags/__init__.py | 2 +- featureflags/graph/graph.py | 66 ++++++++++++++ featureflags/http/db.py | 23 +++++ ...e_added_created_and_reported_timestamps.py | 40 +++++++++ featureflags/models.py | 14 +++ ui/src/Dashboard/Flag.jsx | 86 ++++++++++++++++--- ui/src/Dashboard/Value.jsx | 84 +++++++++++++++--- ui/src/Dashboard/queries.js | 16 ++++ ui/src/Dashboard/utils.js | 10 +++ 9 files changed, 317 insertions(+), 24 deletions(-) create mode 100644 featureflags/migrations/versions/4d42cf3d11de_added_created_and_reported_timestamps.py diff --git a/featureflags/__init__.py b/featureflags/__init__.py index 6849410..a82b376 100644 --- a/featureflags/__init__.py +++ b/featureflags/__init__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" diff --git a/featureflags/graph/graph.py b/featureflags/graph/graph.py index 0958d30..24fbb1f 100644 --- a/featureflags/graph/graph.py +++ b/featureflags/graph/graph.py @@ -249,6 +249,52 @@ async def value_project(ids: list[int]) -> list[int]: return ids +@pass_context +async def get_flag_last_action_timestamp( + ctx: dict, fields: list[Field] +) -> list[str | None]: + if not ctx[GraphContext.USER_SESSION].is_authenticated: + return [] + + [field] = fields + opts = field.options + flag_id = UUID(opts["id"]) + + result = await exec_scalar( + ctx[GraphContext.DB_ENGINE], + ( + select([Changelog.timestamp]) + .where(Changelog.flag == flag_id) + .order_by(Changelog.timestamp.desc()) + .limit(1) + ), + ) + return [str(result) if result else None] + + +@pass_context +async def get_value_last_action_timestamp( + ctx: dict, fields: list[Field] +) -> list[str | None]: + if not ctx[GraphContext.USER_SESSION].is_authenticated: + return [] + + [field] = fields + opts = field.options + value_id = UUID(opts["id"]) + + result = await exec_scalar( + ctx[GraphContext.DB_ENGINE], + ( + select([ValueChangelog.timestamp]) + .where(ValueChangelog.value == value_id) + .order_by(ValueChangelog.timestamp.desc()) + .limit(1) + ), + ) + return [str(result) if result else None] + + ID_FIELD = Field("id", None, id_field) flag_fq = FieldsQuery(GraphContext.DB_ENGINE, Flag.__table__) @@ -260,6 +306,8 @@ async def value_project(ids: list[int]) -> list[int]: Field("name", None, flag_fq), Field("project", None, flag_fq), Field("enabled", None, flag_fq), + Field("created_timestamp", None, flag_fq), + Field("reported_timestamp", None, flag_fq), ], ) @@ -274,6 +322,8 @@ async def value_project(ids: list[int]) -> list[int]: Field("enabled", None, value_fq), Field("value_default", None, value_fq), Field("value_override", None, value_fq), + Field("created_timestamp", None, value_fq), + Field("reported_timestamp", None, value_fq), ], ) @@ -413,6 +463,8 @@ async def value_project(ids: list[int]) -> list[int]: None, flag_sg.c(if_some([S.enabled, S.this.enabled], True, False)), ), + Field("created_timestamp", None, flag_sg), + Field("reported_timestamp", None, flag_sg), ], ) @@ -449,6 +501,8 @@ async def value_project(ids: list[int]) -> list[int]: ), Field("value_default", None, value_sg), Field("value_override", None, value_sg), + Field("created_timestamp", None, value_sg), + Field("reported_timestamp", None, value_sg), ], ) @@ -538,6 +592,18 @@ async def value_project(ids: list[int]) -> list[int]: RootNode = Root( [ + Field( + "flagLastActionTimestamp", + Optional[String], + get_flag_last_action_timestamp, + options=[Option("id", String)], + ), + Field( + "valueLastActionTimestamp", + Optional[String], + get_value_last_action_timestamp, + options=[Option("id", String)], + ), Link( "flag", Optional["Flag"], diff --git a/featureflags/http/db.py b/featureflags/http/db.py index 41eabe4..c27827f 100644 --- a/featureflags/http/db.py +++ b/featureflags/http/db.py @@ -3,6 +3,7 @@ notify server about new projects/variables/flags TODO: refactor. """ +from datetime import datetime from uuid import UUID, uuid4 from aiopg.sa import SAConnection @@ -129,6 +130,14 @@ async def _insert_flag( return await result.scalar() +async def _update_flag_report_timestamp(flag_id: UUID, *, conn: SAConnection): + await conn.execute( + Flag.__table__.update() + .where(Flag.id == flag_id) + .values({Flag.reported_timestamp: datetime.utcnow()}) + ) + + async def _get_or_create_flag( project: UUID, flag: str, @@ -146,6 +155,9 @@ async def _get_or_create_flag( id_ = await _select_flag(project, flag, conn=conn) assert id_ is not None # must be in db entity_cache.flag[project][flag] = id_ + + await _update_flag_report_timestamp(id_, conn=conn) + return id_ @@ -187,6 +199,14 @@ async def _insert_value( return await result.scalar() +async def _update_value_report_timestamp(value_id: UUID, *, conn: SAConnection): + await conn.execute( + Value.__table__.update() + .where(Value.id == value_id) + .values({Value.reported_timestamp: datetime.utcnow()}) + ) + + async def _get_or_create_value( project: UUID, value: str, @@ -205,6 +225,9 @@ async def _get_or_create_value( id_ = await _select_value(project, value, conn=conn) assert id_ is not None # must be in db entity_cache.value[project][value] = id_ + + await _update_value_report_timestamp(id_, conn=conn) + return id_ diff --git a/featureflags/migrations/versions/4d42cf3d11de_added_created_and_reported_timestamps.py b/featureflags/migrations/versions/4d42cf3d11de_added_created_and_reported_timestamps.py new file mode 100644 index 0000000..db2aeb6 --- /dev/null +++ b/featureflags/migrations/versions/4d42cf3d11de_added_created_and_reported_timestamps.py @@ -0,0 +1,40 @@ +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "4d42cf3d11de" +down_revision = "1876f90b58e8" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "flag", + sa.Column("created_timestamp", postgresql.TIMESTAMP(), nullable=True), + ) + op.add_column( + "flag", + sa.Column("reported_timestamp", postgresql.TIMESTAMP(), nullable=True), + ) + op.add_column( + "value", + sa.Column("created_timestamp", postgresql.TIMESTAMP(), nullable=True), + ) + op.add_column( + "value", + sa.Column("reported_timestamp", postgresql.TIMESTAMP(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("value", "reported_timestamp") + op.drop_column("value", "created_timestamp") + op.drop_column("flag", "reported_timestamp") + op.drop_column("flag", "created_timestamp") + # ### end Alembic commands ### diff --git a/featureflags/models.py b/featureflags/models.py index 3b8c5a1..221038c 100644 --- a/featureflags/models.py +++ b/featureflags/models.py @@ -120,6 +120,14 @@ class Flag(Base): id = Column(UUID(as_uuid=True), primary_key=True) name = Column(String, nullable=False) enabled = Column(Boolean) + created_timestamp = Column( + TIMESTAMP, + default=datetime.utcnow(), + nullable=True, + ) + reported_timestamp = Column( + TIMESTAMP, default=datetime.utcnow(), nullable=True + ) project: UUID = Column(ForeignKey("project.id"), nullable=False) @@ -193,6 +201,12 @@ class Value(Base): enabled = Column(Boolean) value_default = Column(String, nullable=False) value_override = Column(String, nullable=False) + created_timestamp = Column( + TIMESTAMP, default=datetime.utcnow(), nullable=True + ) + reported_timestamp = Column( + TIMESTAMP, default=datetime.utcnow(), nullable=True + ) project: UUID = Column(ForeignKey("project.id"), nullable=False) diff --git a/ui/src/Dashboard/Flag.jsx b/ui/src/Dashboard/Flag.jsx index 4be2d9c..ae0ff05 100644 --- a/ui/src/Dashboard/Flag.jsx +++ b/ui/src/Dashboard/Flag.jsx @@ -15,6 +15,7 @@ import { Divider, Popconfirm, message, + Modal, } from 'antd'; import { useEffect, useState } from 'react'; import { @@ -26,12 +27,14 @@ import './Flag.less'; import { FlagContext, useFlagState, - useProject + useProject, } from './context'; import { Conditions } from './Conditions'; import { TYPES, KIND_TO_TYPE, KIND, TYPE_TO_KIND } from './constants'; import { useActions } from './actions'; -import { copyToClipboard, replaceValueInArray } from './utils'; +import { copyToClipboard, formatTimestamp, replaceValueInArray } from './utils'; +import { useLazyQuery } from "@apollo/client"; +import { FLAG_LAST_ACTION_TIMESTAMP_QUERY } from "./queries"; const ResetButton = ({ onClick, disabled }) => { @@ -105,20 +108,72 @@ const Buttons = ({ onReset, onCancel, onSave, onDelete, onToggle }) => { ); } -const FlagName = ({ name }) => { +const FlagTitle = ({ name, flagId, createdTimestamp, reportedTimestamp }) => { + const [ isModalVisible, setIsModalVisible ] = useState(false); + const [ flagHistory, setFlagHistory ] = useState({ + lastAction: "Loading...", + }); + + const [ loadLastActionTimestamp ] = useLazyQuery(FLAG_LAST_ACTION_TIMESTAMP_QUERY, { + fetchPolicy: "network-only", + variables: { id: flagId }, + onCompleted: (data) => { + setFlagHistory({ lastAction: `${data?.flagLastActionTimestamp || "N/A"}` }); + }, + onError: () => { + message.error("Error fetching last action"); + setFlagHistory({ lastAction: "N/A", }); + }, + }); + + const getFlagHistory = () => { + loadLastActionTimestamp(); + setIsModalVisible(true); + }; + + const handleOk = () => { + setIsModalVisible(false); + }; + const copyFlag = () => { copyToClipboard(name, `Flag ${name} copied to clipboard`); } + const TimestampRow = ({ label, timestamp }) => ( +

+ {label}: {formatTimestamp(timestamp)} +

+ ); + return ( -
- - - {name} - +
+
+ + + {name} + +
+ + + OK + , + ]} + > + + + +
) } @@ -131,6 +186,8 @@ const getInitialFlagState = (flag) => ({ enabled: flag.enabled, // TODO sort conditions, because after save, the order is not guaranteed now conditions: flag.conditions.map((c) => c.id), + createdTimestamp: flag.created_timestamp, + reportedTimestamp: flag.reported_timestamp, }); const getInitialConditions = (flag) => { @@ -403,7 +460,12 @@ export const Flag = ({ flag }) => { } + title={} style={{ width: 800, borderRadius: '5px' }} > diff --git a/ui/src/Dashboard/Value.jsx b/ui/src/Dashboard/Value.jsx index 9d447f5..919c111 100644 --- a/ui/src/Dashboard/Value.jsx +++ b/ui/src/Dashboard/Value.jsx @@ -16,6 +16,7 @@ import { Popconfirm, message, Input, + Modal, } from 'antd'; import { useEffect, useState } from 'react'; import { @@ -32,7 +33,9 @@ import { import { ValueConditions } from './ValueConditions'; import { TYPES, KIND_TO_TYPE, KIND, TYPE_TO_KIND } from './constants'; import { useValueActions } from './actions'; -import { copyToClipboard, replaceValueInArray } from './utils'; +import { copyToClipboard, replaceValueInArray, formatTimestamp } from './utils'; +import { useLazyQuery } from "@apollo/client"; +import { VALUE_LAST_ACTION_TIMESTAMP_QUERY } from "./queries"; const ResetButton = ({ onClick, disabled }) => { @@ -118,20 +121,72 @@ const Buttons = ({ onReset, onCancel, onSave, onDelete, onToggle, onValueOverrid ); } -const ValueName = ({ name }) => { +const ValueTitle = ({ name, valueId, createdTimestamp, reportedTimestamp }) => { + const [ isModalVisible, setIsModalVisible ] = useState(false); + const [ valueHistory, setValueHistory ] = useState({ + lastAction: 'Loading...', + }); + + const [ loadLastActionTimestamp ] = useLazyQuery(VALUE_LAST_ACTION_TIMESTAMP_QUERY, { + fetchPolicy: "network-only", + variables: { id: valueId }, + onCompleted: (data) => { + setValueHistory({ lastAction: `${data?.valueLastActionTimestamp || "N/A"}` }); + }, + onError: () => { + message.error("Error fetching last action"); + setValueHistory({ lastAction: "N/A" }); + }, + }); + + const getValueHistory = () => { + loadLastActionTimestamp(); + setIsModalVisible(true); + }; + + const handleOk = () => { + setIsModalVisible(false); + }; + const copyValue = () => { copyToClipboard(name, `Value ${name} copied to clipboard`); } + const TimestampRow = ({ label, timestamp }) => ( +

+ {label}: {formatTimestamp(timestamp)} +

+ ); + return ( -
- - - {name} - +
+
+ + + {name} + +
+ + + OK + , + ]} + > + + + +
) } @@ -146,6 +201,8 @@ const getInitialValueState = (value) => ({ value_override: value.value_override, // TODO sort conditions, because after save, the order is not guaranteed now conditions: value.conditions.map((c) => c.id), + createdTimestamp: value.created_timestamp, + reportedTimestamp: value.reported_timestamp, }); const getInitialConditions = (value) => { @@ -430,7 +487,12 @@ export const Value = ({ value }) => { } + title={} style={{ width: 800, borderRadius: '5px' }} > diff --git a/ui/src/Dashboard/queries.js b/ui/src/Dashboard/queries.js index d73926a..6e6c9fc 100644 --- a/ui/src/Dashboard/queries.js +++ b/ui/src/Dashboard/queries.js @@ -20,6 +20,8 @@ const FLAG_FRAGMENT = gql` name enabled overridden + created_timestamp + reported_timestamp conditions { id checks { @@ -83,6 +85,12 @@ export const FLAGS_QUERY = gql` } `; +export const FLAG_LAST_ACTION_TIMESTAMP_QUERY = gql` + query FlagLastActionTimestamp($id: String!) { + flagLastActionTimestamp(id: $id) + } +`; + const VALUE_FRAGMENT = gql` fragment ValueFragment on Value { id @@ -91,6 +99,8 @@ const VALUE_FRAGMENT = gql` overridden value_default value_override + created_timestamp + reported_timestamp conditions { id value_override @@ -153,3 +163,9 @@ export const VALUES_QUERY = gql` } } `; + +export const VALUE_LAST_ACTION_TIMESTAMP_QUERY = gql` + query ValueLastActionTimestamp($id: String!) { + valueLastActionTimestamp(id: $id) + } +`; diff --git a/ui/src/Dashboard/utils.js b/ui/src/Dashboard/utils.js index 18fe082..601e35e 100644 --- a/ui/src/Dashboard/utils.js +++ b/ui/src/Dashboard/utils.js @@ -14,3 +14,13 @@ export function replaceValueInArray(array, value, newValue) { throw `Value ${value} not found in array ${array}` } } + +export function formatTimestamp(timestamp) { + if (timestamp && timestamp !== "N/A") { + timestamp = timestamp.replace('T', ' '); + return timestamp.split('.')[0] + } + else { + return "N/A" + } +}