Initial Commit for app overview

This commit is contained in:
Dean 2022-05-05 12:52:17 +01:00
parent eeda40f829
commit 5104631d20
12 changed files with 763 additions and 38 deletions

View File

@ -0,0 +1,135 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
let APP_DEV_LOCK_SECONDS = 600
let appLockModal
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
Object.prototype.hasOwnProperty.call(lockedBy, "firstName")
? lockedBy?.firstName
: lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
$: lockExpiry = getExpiryDuration(app)
const getExpiryDuration = app => {
if (!app.lockedBy) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
}
</script>
<div class="lock-status">
{#if lockedBy}
<Button
quiet
secondary
icon="LockClosed"
on:click={() => {
appLockModal.show()
}}
>
<span class="lock-status-text">
{lockedByHeading}
</span>
</Button>
{/if}
</div>
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
dataCy={"app-lock-modal"}
showConfirmButton={false}
showCancelButton={false}
>
<p>
Apps are locked to prevent work from being lost from overlapping changes
between your team.
</p>
{#if lockedByYou && lockExpiry > 0}
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now",
{
time: lockExpiry,
}
)}
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou && lockExpiry > 0}
<Button
secondary
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
<span class="unlock">Release Lock</span>
</Button>
{/if}
</ButtonGroup>
</div>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
}
/* .lock-status :global(.spectrum-Button-label) {
font-weight: 200;
font-family: var(--font-sans);
} */
</style>

View File

@ -0,0 +1,62 @@
<script>
import { Icon, Detail } from "@budibase/bbui"
export let title = ""
export let actionIcon
export let action
$: actionDefined = typeof action === "function"
</script>
<div class="dash-card">
<div
class={actionDefined ? "dash-card-header active" : "dash-card-header"}
on:click={() => {
if (actionDefined) {
action()
}
}}
>
<span class="dash-card-title">
<Detail size="M">{title}</Detail>
</span>
<span class="dash-card-action">
{#if actionDefined}
<Icon name={actionIcon ? actionIcon : "ChevronRight"} />
{/if}
</span>
</div>
<div class="dash-card-body">
<slot />
</div>
</div>
<style>
.dash-card {
background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300);
overflow: hidden;
min-height: 150px;
}
.dash-card-header {
padding: var(--spacing-l);
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
display: flex;
justify-content: space-between;
}
.dash-card-body {
padding: var(--spacing-l);
}
.dash-card-title :global(.spectrum-Detail) {
color: var(
--spectrum-sidenav-heading-text-color,
var(--spectrum-global-color-gray-700)
);
display: inline-block;
}
.dash-card-header.active:hover {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
</style>

View File

@ -1,22 +1,15 @@
<script> <script>
import { import { Heading, Button, Icon, ActionMenu, MenuItem } from "@budibase/bbui"
Heading, import AppLockModal from "../common/AppLockModal.svelte"
Button,
Icon,
ActionMenu,
MenuItem,
StatusLight,
} from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
export let app export let app
export let exportApp export let exportApp
export let viewApp
export let editApp export let editApp
export let updateApp export let updateApp
export let deleteApp export let deleteApp
export let previewApp
export let unpublishApp export let unpublishApp
export let appOverview
export let releaseLock export let releaseLock
export let editIcon export let editIcon
</script> </script>
@ -26,7 +19,7 @@
<div class="app-icon" style="color: {app.icon?.color || ''}"> <div class="app-icon" style="color: {app.icon?.color || ''}">
<Icon size="XL" name={app.icon?.name || "Apps"} /> <Icon size="XL" name={app.icon?.name || "Apps"} />
</div> </div>
<div class="name" on:click={() => editApp(app)}> <div class="name" on:click={() => appOverview(app)}>
<Heading size="XS"> <Heading size="XS">
{app.name} {app.name}
</Heading> </Heading>
@ -43,19 +36,7 @@
{/if} {/if}
</div> </div>
<div class="desktop"> <div class="desktop">
<StatusLight <AppLockModal {app} />
positive={!app.lockedYou && !app.lockedOther}
notice={app.lockedYou}
negative={app.lockedOther}
>
{#if app.lockedYou}
Locked by you
{:else if app.lockedOther}
Locked by {app.lockedBy.email}
{:else}
Open
{/if}
</StatusLight>
</div> </div>
<div class="desktop"> <div class="desktop">
<div class="app-status"> <div class="app-status">
@ -70,23 +51,15 @@
</div> </div>
<div data-cy={`row_actions_${app.appId}`}> <div data-cy={`row_actions_${app.appId}`}>
<div class="app-row-actions"> <div class="app-row-actions">
{#if app.deployed}
<Button size="S" secondary quiet on:click={() => viewApp(app)}
>View app
</Button>
{:else}
<Button size="S" secondary quiet on:click={() => previewApp(app)}
>Preview
</Button>
{/if}
<Button <Button
size="S" size="S"
cta secondary
quiet
disabled={app.lockedOther} disabled={app.lockedOther}
on:click={() => editApp(app)} on:click={() => editApp(app)}
> >Edit
Edit
</Button> </Button>
<Button size="S" cta on:click={() => appOverview(app)}>View</Button>
</div> </div>
<ActionMenu align="right" dataCy="app-row-actions-menu-popover"> <ActionMenu align="right" dataCy="app-row-actions-menu-popover">
<span slot="control" class="app-row-actions-icon"> <span slot="control" class="app-row-actions-icon">
@ -119,6 +92,7 @@
} }
.app-status { .app-status {
display: grid; display: grid;
grid-gap: var(--spacing-s);
grid-template-columns: 24px 100px; grid-template-columns: 24px 100px;
} }
.app-status span.disabled { .app-status span.disabled {

View File

@ -182,6 +182,10 @@
window.open(`/${app.devId}`) window.open(`/${app.devId}`)
} }
const appOverview = app => {
$goto(`../overview/${app.devId}`)
}
const editApp = app => { const editApp = app => {
if (app.lockedOther) { if (app.lockedOther) {
notifications.error( notifications.error(
@ -404,6 +408,7 @@
{deleteApp} {deleteApp}
{updateApp} {updateApp}
{previewApp} {previewApp}
{appOverview}
/> />
{/each} {/each}
</div> </div>

View File

@ -0,0 +1,254 @@
<script>
import { goto } from "@roxi/routify"
import {
Layout,
Page,
Button,
ButtonGroup,
Heading,
Tab,
Tabs,
notifications,
Icon,
ProgressCircle,
} from "@budibase/bbui"
import OverviewTab from "../_components/OverviewTab.svelte"
import VersionTab from "../_components/VersionTab.svelte"
import SelfHostTab from "../_components/SelfHostTab.svelte"
import { API } from "api"
import { onMount } from "svelte"
import { store } from "builderStore"
import { roles, flags } from "stores/backend"
import { apps, auth } from "stores/portal"
import analytics, { Events, EventSource } from "analytics"
import { AppStatus } from "constants"
import AppLockModal from "../../../../../components/common/AppLockModal.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
export let application
let promise = getPackage()
// App
$: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
// Locking
$: lockedBy = selectedApp?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
Object.prototype.hasOwnProperty.call(lockedBy, "firstName")
? lockedBy?.firstName
: lockedBy?.email
}`
// App deployments
$: deployments = []
$: latestDeployments = deployments
.filter(
deployment =>
deployment.status === "SUCCESS" && application === deployment.appId
)
.sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished =
selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0
$: appUrl = `${window.origin}/app${selectedApp?.url}`
$: tabs = [
"Overview",
"Automation History",
"Backups",
"App Version",
"Self-host",
]
$: selectedTab = "Overview"
const handleTabChange = tabKey => {
if (tabKey === selectedTab) {
return
} else if (tabKey && tabs.indexOf(tabKey) > -1) {
selectedTab = tabKey
} else {
notifications.error("Invalid tab key")
}
}
async function getPackage() {
try {
const pkg = await API.fetchAppPackage(application)
await store.actions.initialise(pkg)
// await automationStore.actions.fetch()
await roles.fetch()
await flags.fetch()
return pkg
} catch (error) {
notifications.error(`Error initialising app: ${error?.message}`)
}
}
const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments.length > 0) {
const pending = checkIncomingDeploymentStatus(deployments, newDeployments)
if (pending.length) {
notifications.warning(
"Deployment has been queued and will be processed shortly"
)
}
}
}
async function fetchDeployments() {
try {
const newDeployments = await API.getAppDeployments()
reviewPendingDeployments(deployments, newDeployments)
return newDeployments
} catch (err) {
notifications.error("Error fetching deployment history")
}
}
//Show prod: published, appUrl
//Behaviour incorrect. It should be enabled if at least 1 live deployment is available
const viewApp = () => {
if (isPublished) {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(appUrl, "_blank")
}
}
const editApp = app => {
if (lockedBy && !lockedByYou) {
notifications.warn(
`App locked by ${lockIdentifer}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`)
}
onMount(async () => {
// if (!hasSynced && application) {
// try {
// await API.syncApp(application)
// } catch (error) {
// notifications.error("Failed to sync with production database")
// }
// hasSynced = true
// }
deployments = await fetchDeployments()
})
</script>
<Page wide>
<Layout noPadding gap="XL">
<span>
<Button
quiet
secondary
icon={"ChevronLeft"}
on:click={() => {
$goto("../../")
}}
>
Back
</Button>
</span>
{#await promise}
<div class="loading">
<ProgressCircle size="XL" />
</div>
{:then _}
<div class="overview-header">
<div class="app-title">
<div class="app-logo">
<div
class="app-icon"
style="color: {selectedApp?.icon?.color || ''}"
>
<Icon size="XL" name={selectedApp?.icon?.name || "Apps"} />
</div>
</div>
<div class="app-details">
<Heading size="M">{selectedApp?.name}</Heading>
<div class="app-url">{appUrl}</div>
</div>
</div>
<div class="header-right">
<AppLockModal app={selectedApp} />
<ButtonGroup gap="XS">
<Button
size="M"
secondary
icon="Globe"
disabled={!isPublished}
on:click={viewApp}>View app</Button
>
<Button size="M" cta icon="Edit" on:click={editApp(selectedApp)}>
<span>Edit</span>
</Button>
</ButtonGroup>
</div>
</div>
<Tabs
selected={selectedTab}
noPadding
on:select={e => {
selectedTab = e.detail
}}
>
<Tab title="Overview">
<OverviewTab
app={selectedApp}
deployments={latestDeployments}
navigateTab={handleTabChange}
/>
</Tab>
<Tab title="Automation History">
<div class="container">Automation History contents</div>
</Tab>
<Tab title="Backups">
<div class="container">Backups contents</div>
</Tab>
<Tab title="App Version">
<VersionTab app={selectedApp} />
</Tab>
<Tab title="Self-host">
<SelfHostTab />
</Tab>
</Tabs>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
</Layout>
</Page>
<style>
.app-url {
color: var(--spectrum-global-color-gray-600);
}
.loading {
display: flex;
justify-content: center;
}
.overview-header {
display: flex;
justify-content: space-between;
}
.app-title {
display: flex;
gap: var(--spacing-m);
}
.header-right {
display: flex;
align-items: center;
gap: var(--spacing-xl);
}
.app-details :global(.spectrum-Heading) {
line-height: 1em;
margin-bottom: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,11 @@
<script>
//export let app
</script>
<div class="automation-tab" />
<style>
.automation-tab {
color: pink;
}
</style>

View File

@ -0,0 +1,174 @@
<script>
import DashCard from "../../../../../components/common/DashCard.svelte"
import { AppStatus } from "constants"
import { Icon, Heading, Link, Avatar } from "@budibase/bbui"
import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json"
import { processStringSync } from "@budibase/string-templates"
export let app
export let deployments
export let navigateTab
$: updateAvailable = clientPackage.version !== $store.version
$: isPublished = app.status === AppStatus.DEPLOYED
</script>
<div class="overview-tab">
<div class="top">
<DashCard title={"App Status"}>
<div class="status-content">
<div class="status-display">
{#if isPublished}
<Icon name="GlobeCheck" size="XL" disabled={false} />
<span>Published</span>
{:else}
<Icon name="GlobeStrike" size="XL" disabled={true} />
<span class="disabled"> Unpublished </span>
{/if}
</div>
<p class="status-text">
{#if deployments?.length}
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)}
{/if}
{#if !deployments?.length}
-
{/if}
</p>
</div>
</DashCard>
<DashCard title={"Last Edited"}>
{app.updatedAt}
<div class="last-edited-content">
<!-- Where is this information sourced? auditLog > Placeholder, metadata -->
<div class="updated-by">
<!-- Add a link to the user? new window? -->
<Avatar size="M" initials={app.updatedBy.initials} />
<div>{app.updatedBy.firstName}</div>
</div>
<p class="last-edit-text">
{#if app}
{processStringSync(
"Last edited {{ duration time 'millisecond' }} ago",
{
time: new Date().getTime() - new Date(app?.updatedAt).getTime(),
}
)}
{/if}
</p>
</div>
</DashCard>
<DashCard
title={"App Version"}
showIcon={true}
action={() => {
navigateTab("App Version")
}}
>
<div class="version-content">
<Heading size="XS">{app?.version}</Heading>
{#if updateAvailable}
<p>
New version <strong>{clientPackage.version}</strong> is available -
<Link
on:click={() => {
if (typeof navigateTab === "function") {
navigateTab("App Version")
}
}}
>
Update
</Link>
</p>
{:else}
<p>You're running the latest!</p>
{/if}
</div>
</DashCard>
</div>
<div class="bottom">
<DashCard
title={"Automation History"}
action={() => {
navigateTab("Automation History")
}}
>
<div class="automation-content">
<div class="automation-metrics">
<div class="succeeded">
<Heading size="XL">0</Heading>
<div class="metric-info">
<Icon name="CheckmarkCircle" />
Success
</div>
</div>
<div class="failed">
<Heading size="XL">0</Heading>
<div class="metric-info">
<Icon name="Alert" />
Error
</div>
</div>
</div>
</div>
</DashCard>
<DashCard title={"Backups"}>
<div class="backups-content">test</div>
</DashCard>
</div>
</div>
<style>
/* Add in size checks */
.overview-tab {
display: grid;
grid-gap: var(--spacing-xl);
}
.overview-tab .top {
display: grid;
grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
}
.overview-tab .bottom,
.automation-metrics {
display: grid;
grid-gap: var(--spacing-xl);
grid-template-columns: 1fr 1fr;
}
.status-display {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.status-text,
.last-edit-text {
color: var(--spectrum-global-color-gray-600);
}
.updated-by {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.succeeded :global(.icon) {
color: var(--spectrum-global-color-green-600);
}
.failed :global(.icon) {
color: var(
--spectrum-semantic-negative-color-default,
var(--spectrum-global-color-red-500)
);
}
.metric-info {
display: flex;
gap: var(--spacing-l);
margin-top: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,37 @@
<script>
import { Layout, Divider, Heading, Body, Page, Button } from "@budibase/bbui"
const selfHostPath =
"https://docs.budibase.com/docs/hosting-methods#self-host-budibase"
</script>
<div class="self-host-tab">
<Page wide={false}>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Self-host Budibase</Heading>
<Divider />
<Body>
<p>
Self-host Budibase for free to get unlimited apps and more - and it
only takes a few minutes!
</p>
<div class="page-action">
<Button
cta
on:click={() => {
window.open(selfHostPath, "_blank")
}}>Self-host Budibase</Button
>
</div>
</Body>
</Layout>
</Layout>
</Page>
</div>
<style>
.page-action {
padding-top: var(--spacing-xl);
}
</style>

View File

@ -0,0 +1,48 @@
<script>
import { Layout, Divider, Heading, Body, Page, Button } from "@budibase/bbui"
import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json"
export let app
//Review locking behaviour.
//The app just needs to be unlocked/lockedByYou to proceed?
$: updateAvailable = clientPackage.version !== $store.version
</script>
<div class="version-tab">
<Page wide={false}>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>App Version</Heading>
<Divider />
<Body>
{#if updateAvailable}
<p>
The app is currently using version <strong>{app?.version}</strong>
but version <strong>{clientPackage.version}</strong> is available.
</p>
{:else}
<p>
The app is currently using version <strong>{app?.version}</strong
>. You're running the latest!
</p>
{/if}
<p>
Updates can contain new features, performance improvements and bug
fixes.
</p>
<div class="page-action">
<Button cta on:click={() => {}}>Update App</Button>
</div>
</Body>
</Layout>
</Layout>
</Page>
</div>
<style>
.page-action {
padding-top: var(--spacing-xl);
}
</style>

View File

@ -6,7 +6,7 @@ const {
setDebounce, setDebounce,
} = require("../utilities/redis") } = require("../utilities/redis")
const { doWithDB } = require("@budibase/backend-core/db") const { doWithDB } = require("@budibase/backend-core/db")
const { DocumentTypes } = require("../db/utils") const { DocumentTypes, getGlobalIDFromUserMetadataID } = require("../db/utils")
const { PermissionTypes } = require("@budibase/backend-core/permissions") const { PermissionTypes } = require("@budibase/backend-core/permissions")
const { app: appCache } = require("@budibase/backend-core/cache") const { app: appCache } = require("@budibase/backend-core/cache")
@ -51,6 +51,21 @@ async function updateAppUpdatedAt(ctx) {
await doWithDB(appId, async db => { await doWithDB(appId, async db => {
const metadata = await db.get(DocumentTypes.APP_METADATA) const metadata = await db.get(DocumentTypes.APP_METADATA)
metadata.updatedAt = new Date().toISOString() metadata.updatedAt = new Date().toISOString()
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials == "" ? undefined : initials
}
metadata.updatedBy = {
email: ctx.user.email,
firstName: ctx.user.firstName,
lastName: ctx.user.lastName,
initials: getInitials(ctx.user),
_id: getGlobalIDFromUserMetadataID(ctx.user.userId),
}
const response = await db.put(metadata) const response = await db.put(metadata)
metadata._rev = response.rev metadata._rev = response.rev
await appCache.invalidateAppMetadata(appId, metadata) await appCache.invalidateAppMetadata(appId, metadata)
@ -67,7 +82,15 @@ module.exports = async (ctx, permType) => {
} }
const isBuilderApi = permType === PermissionTypes.BUILDER const isBuilderApi = permType === PermissionTypes.BUILDER
const referer = ctx.headers["referer"] const referer = ctx.headers["referer"]
const editingApp = referer ? referer.includes(appId) : false
const overviewPath = "/builder/portal/overview/"
const overviewContext = !referer ? false : referer.includes(overviewPath)
if (overviewContext) {
return
}
const hasAppId = !referer ? false : referer.includes(appId)
const editingApp = referer ? hasAppId : false
// check this is a builder call and editing // check this is a builder call and editing
if (!isBuilderApi || !editingApp) { if (!isBuilderApi || !editingApp) {
return return

View File

@ -48,7 +48,9 @@ exports.updateLock = async (devAppId, user) => {
...user, ...user,
userId: globalId, userId: globalId,
_id: globalId, _id: globalId,
lockedAt: new Date().getTime(),
} }
await devAppClient.store(devAppId, inputUser, APP_DEV_LOCK_SECONDS) await devAppClient.store(devAppId, inputUser, APP_DEV_LOCK_SECONDS)
} }