Skip to content
This repository has been archived by the owner on Dec 26, 2023. It is now read-only.

Commit

Permalink
feat(ui): show resources and outputs on workspace page (#542)
Browse files Browse the repository at this point in the history
Fixes #308
  • Loading branch information
leg100 committed Jul 27, 2023
1 parent ed67d57 commit d792e23
Show file tree
Hide file tree
Showing 46 changed files with 344 additions and 89 deletions.
Binary file modified docs/images/connected_workspace_main_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/modules_confirm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/modules_list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/modules_select_provider.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/modules_select_repo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/new_github_vcs_provider_form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/new_org_created.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/newly_created_module_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/org_token_created.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/org_token_new.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/organization_main_menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/owners_team_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/run_page_planned_and_finished_state.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/run_page_planned_state.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/run_page_started.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/team_permissions_added_workspace_manager.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/terraform_login_consent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/user_token_created.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/variables_entering_top_secret.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/vcs_providers_list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/workspace_main_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/workspace_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/workspace_permissions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/workspace_settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/workspace_vcs_providers_list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/workspace_vcs_repo_list.png
2 changes: 2 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
DB: db,
WorkspaceAuthorizer: workspaceService,
Cache: cache,
Renderer: renderer,
})
variableService := variable.NewService(variable.Options{
Logger: logger,
Expand Down Expand Up @@ -293,6 +294,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
authService,
tokensService,
workspaceService,
stateService,
orgService,
variableService,
vcsProviderService,
Expand Down
1 change: 1 addition & 0 deletions internal/http/html/paths/funcmap.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions internal/http/html/paths/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ var specs = []controllerSpec{
{
name: "delete-tag",
},
{
name: "state",
},
},
nested: []controllerSpec{
{
Expand Down
4 changes: 4 additions & 0 deletions internal/http/html/paths/workspace_paths.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions internal/http/html/static/css/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,13 @@
.field > label {
@apply block font-semibold;
}
table {
@apply text-sm;
}
tr {
@apply even:bg-gray-100;
}
th, td {
@apply p-2;
}
}
14 changes: 14 additions & 0 deletions internal/http/html/static/css/output.css
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,20 @@ select {
font-weight: 600;
}

table {
font-size: 0.875rem;
line-height: 1.25rem;
}

tr:nth-child(even) {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}

th, td {
padding: 0.5rem;
}

*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
Expand Down
74 changes: 74 additions & 0 deletions internal/http/html/static/templates/content/state_get.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<div class="" x-data="{ activeTab: 'resources' }">
<div class="flex">
<label
class="p-2 border cursor-pointer" for="resources-tab"
@click="activeTab = 'resources'"
:class="{ 'bg-gray-200 text-black border-black': activeTab == 'resources' }"
id="resources-label"
>Resources ({{ len .Resources }})</label>
<label
class="p-2 border cursor-pointer" for="outputs-tab"
@click="activeTab = 'outputs'"
:class="{ 'bg-gray-200 text-black border-black': activeTab == 'outputs' }"
id="outputs-label"
>Outputs ({{ len .Outputs }})</label>
</div>
<table
x-show="activeTab == 'resources'"
class="table-fixed w-full text-left break-words border-collapse"
id="resources-table"
>
{{ with .Resources }}
<thead class="bg-gray-200 border-t border-b border-slate-900">
<tr>
<th>Name</th>
<th>Provider</th>
<th>Type</th>
<th>Module</th>
</tr>
</thead>
{{ end }}
<tbody>
{{ range .Resources }}
<tr class="even:bg-gray-100">
<td>{{ .Name }}</td>
<td>{{ .Provider }}</td>
<td>{{ .Type }}</td>
<td>{{ .ModuleName }}</td>
</tr>
{{ else }}
<tr>
<td>No resources currently exist.</td>
</tr>
{{ end }}
</tbody>
</table>
<table
x-show="activeTab == 'outputs'"
class="table-fixed w-full text-left break-words border-collapse"
id="outputs-table"
>
{{ with .Outputs }}
<thead class="bg-gray-200 border-t border-b border-slate-900">
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
{{ end }}
<tbody>
{{ range $k, $v := .Outputs }}
<tr>
<td>{{ $k }}</td>
<td>{{ $v.Type }}</td>
<td><span class="bg-gray-200">{{ $v.StringValue }}</span></td>
</tr>
{{ else }}
<tr>
<td>No outputs currently exist.</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{{ define "content" }}
{{ $canCreate := $.CurrentUser.CanAccessWorkspace .CreateVariableAction .Policy }}
{{ $canDelete := $.CurrentUser.CanAccessWorkspace .DeleteVariableAction .Policy }}
<table class="table-fixed w-full text-left break-words border-collapse text-sm" id="variables-table">
<table class="table-fixed w-full text-left break-words border-collapse" id="variables-table">
<thead class="bg-gray-200 border-t border-b border-slate-900">
<tr>
<th class="p-2 w-[25%]">Key</th>
Expand Down
23 changes: 14 additions & 9 deletions internal/http/html/static/templates/content/workspace_get.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@
{{ end }}

{{ define "content" }}
<div class="flex gap-4 flex-row">
<div class="grow">
<div class="flex gap-6 flex-row">
<div class="grow flex flex-col gap-4">
<div>{{ template "identifier" .Workspace }}</div>
<h3 class="text-lg font-bold my-2">Latest Run</h3>
<div id="latest-run" hx-ext="sse" sse-connect="{{ watchWorkspacePath .Workspace.ID }}?latest=true" sse-swap="latest-run">
{{ if .Workspace.LatestRun }}
<div hx-get="{{ widgetRunPath .Workspace.LatestRun.ID }}" hx-trigger="load" hx-swap="outerHTML"></div>
{{ else }}
There are no runs for this workspace.
{{ end }}
<div>
<h3 class="text-lg font-bold my-2">Latest Run</h3>
<div id="latest-run" hx-ext="sse" sse-connect="{{ watchWorkspacePath .Workspace.ID }}?latest=true" sse-swap="latest-run">
{{ if .Workspace.LatestRun }}
<div hx-get="{{ widgetRunPath .Workspace.LatestRun.ID }}" hx-trigger="load" hx-swap="outerHTML"></div>
{{ else }}
There are no runs for this workspace.
{{ end }}
</div>
</div>
<div>
<div hx-get="{{ stateWorkspacePath .Workspace.ID }}" hx-trigger="load" hx-swap="innerHTML"></div>
</div>
</div>
<div class="flex gap-4 flex-col basis-52">
Expand Down
47 changes: 47 additions & 0 deletions internal/integration/state_ui_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package integration

import (
"testing"

"github.com/chromedp/chromedp"
"github.com/leg100/otf/internal"
"github.com/leg100/otf/internal/run"
"github.com/stretchr/testify/require"
)

// TestIntegration_StateUI demonstrates the displaying of terraform state via
// the UI
func TestIntegration_StateUI(t *testing.T) {
integrationTest(t)

daemon, org, ctx := setup(t, nil)

// create run and wait for it to complete
ws := daemon.createWorkspace(t, ctx, org)
cv := daemon.createAndUploadConfigurationVersion(t, ctx, ws, nil)
_ = daemon.createRun(t, ctx, ws, cv)
applied:
for event := range daemon.sub {
if r, ok := event.Payload.(*run.Run); ok {
switch r.Status {
case internal.RunApplied:
break applied
case internal.RunPlanned:
err := daemon.Apply(ctx, r.ID)
require.NoError(t, err)
case internal.RunErrored:
t.Fatal("run unexpectedly finished with an error")
}
}
}

browser.Run(t, ctx, chromedp.Tasks{
chromedp.Navigate(workspaceURL(daemon.Hostname(), org.Name, ws.Name)),
matchRegex(t, `//label[@id='resources-label']`, `Resources \(1\)`),
matchRegex(t, `//label[@id='outputs-label']`, `Outputs \(0\)`),
matchText(t, `//table[@id='resources-table']/tbody/tr/td[1]`, `test`),
matchText(t, `//table[@id='resources-table']/tbody/tr/td[2]`, `hashicorp/null`),
matchText(t, `//table[@id='resources-table']/tbody/tr/td[3]`, `null_resource`),
matchText(t, `//table[@id='resources-table']/tbody/tr/td[4]`, `root`),
})
}
4 changes: 2 additions & 2 deletions internal/state/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,15 @@ func newVersion(opts newVersionOptions) (Version, error) {
// extract outputs from state file
outputs := make(map[string]*Output, len(f.Outputs))
for k, v := range f.Outputs {
hclType, err := newHCLType(v.Value)
typ, err := v.Type()
if err != nil {
return Version{}, err
}

outputs[k] = &Output{
ID: internal.NewID("wsout"),
Name: k,
Type: hclType,
Type: typ,
Value: v.Value,
Sensitive: v.Sensitive,
StateVersionID: sv.ID,
Expand Down
87 changes: 75 additions & 12 deletions internal/state/file.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,80 @@
package state

import "encoding/json"

// File is the terraform state file contents
type File struct {
Version int
Serial int64
Lineage string
Outputs map[string]FileOutput
import (
"encoding/json"
"regexp"
"strings"
)

var providerPathRegex = regexp.MustCompile(`provider\[".*?/([^"]+)"\]`)

type (
// File is the terraform state file contents
File struct {
Version int
Serial int64
Lineage string
Outputs map[string]FileOutput
Resources []Resource
}

// FileOutput is an output in the terraform state file
FileOutput struct {
Value json.RawMessage
Sensitive bool
}

Resource struct {
Name string
ProviderURI string `json:"provider"`
Type string
Module string
}
)

// Provider extracts the provider from the provider URI
func (r Resource) Provider() string {
matches := providerPathRegex.FindStringSubmatch(r.ProviderURI)
if matches == nil || len(matches) < 2 {
return r.ProviderURI
}
return matches[1]
}

func (r Resource) ModuleName() string {
if r.Module == "" {
return "root"
}
return strings.TrimPrefix(r.Module, "module.")
}

// Type determines the HCL type of the output value
func (r FileOutput) Type() (string, error) {
var dst any
if err := json.Unmarshal(r.Value, &dst); err != nil {
return "", err
}

var typ string
switch dst.(type) {
case bool:
typ = "bool"
case float64:
typ = "number"
case string:
typ = "string"
case []any:
typ = "tuple"
case map[string]any:
typ = "object"
case nil:
typ = "null"
default:
typ = "unknown"
}
return typ, nil
}

// FileOutput is an output in the terraform state file
type FileOutput struct {
Value json.RawMessage
Sensitive bool
func (r FileOutput) StringValue() string {
return string(r.Value)
}
34 changes: 34 additions & 0 deletions internal/state/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,37 @@ func TestFile_Unmarshal(t *testing.T) {
}
// skip testing output values because they're not unmarshaled
}

func TestFile_Provider(t *testing.T) {
got := Resource{
ProviderURI: `provider": "provider["registry.terraform.io/hashicorp/null"]`,
}
want := "hashicorp/null"
assert.Equal(t, want, got.Provider())
}

func TestFile_Type(t *testing.T) {
tests := []struct {
want string
value string
}{
{"bool", `true`},
{"bool", `false`},
{"number", `0.339`},
{"number", `42`},
{"string", `"item"`},
{"tuple", `["item1", "item2"]`},
{"object", `{"key1": "value1", "key2": "value2"}`},
{"null", `null`},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
out := FileOutput{
Value: []byte(tt.value),
}
got, err := out.Type()
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
Loading

0 comments on commit d792e23

Please sign in to comment.