-
Notifications
You must be signed in to change notification settings - Fork 5
/
view.go
318 lines (268 loc) · 8.13 KB
/
view.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/neuralinkcorp/tsui/libts"
"github.com/neuralinkcorp/tsui/ui"
"tailscale.com/ipn"
)
// Format the status button in the header bar.
func renderStatusButton(backendState ipn.State, isUsingExitNode bool) string {
buttonStyle := lipgloss.NewStyle().
Padding(0, 1)
switch backendState {
case ipn.NeedsLogin:
return buttonStyle.
Background(ui.Yellow).
Foreground(ui.Black).
Render("Needs Login")
case ipn.NeedsMachineAuth:
return buttonStyle.
Background(ui.Yellow).
Foreground(ui.Black).
Render("Needs Machine Auth")
case ipn.Starting:
return buttonStyle.
Background(ui.Blue).
Foreground(ui.White).
Render("Starting...")
case ipn.Running:
text := "Connected"
if isUsingExitNode {
text += " - Exit Node"
}
return buttonStyle.
Background(ui.Green).
Foreground(ui.Black).
Render(text)
case ipn.Stopped:
return buttonStyle.
Background(ui.Red).
Foreground(ui.Black).
Render("Not Connected")
case ipn.NoState:
return buttonStyle.
Background(ui.Blue).
Foreground(ui.White).
Render("Loading...")
}
return "???"
}
// Render the locked out warning. Returns static output; should be called conditionally.
func renderLockedOutWarning(m *model) string {
heading := lipgloss.NewStyle().
Background(ui.Yellow).
Foreground(ui.Black).
Bold(true).
Padding(0, 1).
Render("Warning: Locked Out")
bodyText := "This node is locked out by tailnet lock. Please contact an administrator of your Tailscale network to authorize your connection."
lockedOutWarning := lipgloss.NewStyle().
Foreground(ui.Yellow).
Width(80).
Align(lipgloss.Center).
Render(heading + "\n" + bodyText)
return lipgloss.PlaceHorizontal(m.terminalWidth, lipgloss.Center, lockedOutWarning)
}
// Format the top header section.
func renderHeader(m *model) string {
logo := lipgloss.NewStyle().
Foreground(ui.Primary).
MarginRight(4).
Render(ui.Logo)
var statusStr string
{
var status strings.Builder
status.WriteString("Status: ")
status.WriteString(renderStatusButton(m.state.BackendState, m.state.CurrentExitNode != nil))
if m.state.BackendState == ipn.Running {
status.WriteString(lipgloss.NewStyle().
Faint(true).
PaddingLeft(1).
Render("(press . to disconnect)"))
}
status.WriteByte('\n')
// Extra info; either auth URL or user login name, depending on the backend state.
if m.state.User == nil || m.state.User.LoginName == "" {
status.WriteString(lipgloss.NewStyle().
Faint(true).
Render("--"))
} else {
status.WriteString(lipgloss.NewStyle().
Faint(true).
Render(m.state.User.LoginName))
}
statusStr = status.String()
}
var versionsStr string
{
// App versions.
var versions strings.Builder
versions.WriteString("tsui: " + Version + "\n")
versions.WriteString("tailscale: ")
if m.state.TSVersion != "" {
versions.WriteString(m.state.TSVersion)
} else {
versions.WriteString("(not connected)")
}
versionsStr = lipgloss.NewStyle().
Faint(true).
Render(versions.String())
}
// Spacer between the left content and the right content.
spacer := lipgloss.NewStyle().
Width(m.terminalWidth - lipgloss.Width(versionsStr) - lipgloss.Width(statusStr) - lipgloss.Width(logo)).
Render(" ")
return lipgloss.JoinHorizontal(lipgloss.Center, logo, statusStr, spacer, versionsStr)
}
// Render a banner/modal for the middle of the screen.
func renderMiddleBanner(m *model, height int, text string) string {
divider := lipgloss.NewStyle().
Faint(true).
Render(strings.Repeat("=", lipgloss.Width(text)))
return lipgloss.Place(m.terminalWidth, height, lipgloss.Center, lipgloss.Center,
divider+"\n\n"+text+"\n\n"+divider)
}
// Render the bottom status bar.
func renderStatusBar(m *model) string {
var text string
if m.statusText == "" && m.canWrite && m.state.BackendState == ipn.Running {
// If there's no other status, we're running, and we have write access, show up/down.
text = lipgloss.NewStyle().
Faint(true).
Render(fmt.Sprintf(
"▼ %s | %s ▲",
ui.FormatBytes(m.state.RxBytes),
ui.FormatBytes(m.state.TxBytes),
))
} else if m.statusText == "" && !m.canWrite {
// If there's no other status and we don't have write access, show a read-only warning.
text = lipgloss.NewStyle().
Bold(true).
Foreground(ui.Yellow).
Render("Read-only mode.")
text += lipgloss.NewStyle().
Foreground(ui.Yellow).
Render(" To edit preferences, you may have to run tsui as root.")
} else if m.statusText != "" {
// Otherwise, there's a status message, so render it.
var color lipgloss.Color
switch m.statusType {
case statusTypeError:
color = ui.Red
text = lipgloss.NewStyle().
Foreground(color).
Bold(true).
Render("Error: ")
case statusTypeSuccess:
color = ui.Green
case statusTypeTip:
color = ui.Blue
text = lipgloss.NewStyle().
Foreground(color).
Bold(true).
Render("Tip! ")
}
text += lipgloss.NewStyle().
Foreground(color).
Render(m.statusText)
}
right := lipgloss.NewStyle().
Faint(true).
Render("press q to quit")
left := lipgloss.NewStyle().
Width(m.terminalWidth - lipgloss.Width(right)).
PaddingLeft(lipgloss.Width(right)).
Align(lipgloss.Center).
Render(text)
return left + right
}
// Bubbletea's main render function. Called after state updates.
func (m model) View() string {
// Don't render anything before we have our initial terminal info.
if m.terminalWidth == 0 || m.terminalHeight == 0 {
return ""
}
// Render the top of the page (header bar, locked out warning, etc).
top := renderHeader(&m) + "\n\n"
if m.state.IsLockedOut {
top += renderLockedOutWarning(&m) + "\n\n"
}
top += "\n"
// Render the bottom of the page (status bar, error text, etc).
bottom := "\n" + renderStatusBar(&m)
// Now, draw the middle, and make it take up the remaining space.
middleHeight := m.terminalHeight - lipgloss.Height(top) - lipgloss.Height(bottom)
var middle string
styledAuthUrl := lipgloss.NewStyle().
Underline(true).
Foreground(ui.Blue).
Render(m.state.AuthURL)
switch m.state.BackendState {
case ipn.Running:
middle = lipgloss.NewStyle().
Height(middleHeight).
Render(m.menu.Render(middleHeight))
case ipn.NeedsMachineAuth:
// TODO: Figure out what this state actually is so we can be helpful to the user.
middle = renderMiddleBanner(&m, middleHeight, "Tailscale status is NeedsMachineAuth.")
case ipn.NeedsLogin:
lines := []string{
lipgloss.NewStyle().
Bold(true).
Render(`Login Required`),
``,
`You need to login to Tailscale before you can connect to the tailnet.`,
``,
}
if m.state.AuthURL == "" {
lines = append(lines,
`Press . to authenticate.`,
)
} else {
lines = append(lines,
fmt.Sprintf(`Login URL: %s`, styledAuthUrl),
)
if libts.StartLoginInteractiveWillOpenBrowser() {
lines = append(lines,
``,
`Press . to open in browser.`,
)
}
}
middle = renderMiddleBanner(&m, middleHeight, strings.Join(lines, "\n"))
case ipn.Stopped:
middle = renderMiddleBanner(&m, middleHeight, strings.Join([]string{
`The Tailscale daemon isn't running.`,
``,
`Press . to bring Tailscale up.`,
}, "\n"))
case ipn.NoState:
middle = renderMiddleBanner(&m, middleHeight, ui.PoggersAnimationFrame(m.animationT))
case ipn.Starting:
if m.state.AuthURL == "" {
middle = renderMiddleBanner(&m, middleHeight, ui.PoggersAnimationFrame(m.animationT))
} else {
// If we have an AuthURL in the Starting state, that means the user is reauthenticating.
// TODO: This case only seems to show up sometimes, and may not anymore (?) in the latest
// version of Tailscale.
lines := []string{
lipgloss.NewStyle().
Bold(true).
Render(`Reauthenticate with Tailscale`),
``,
fmt.Sprintf(`Login URL: %s`, styledAuthUrl),
}
if libts.StartLoginInteractiveWillOpenBrowser() {
// We can't open the browser for them if running as the root user on Linux.
lines = append(lines,
``,
`Press . to open in browser.`,
)
}
middle = renderMiddleBanner(&m, middleHeight, strings.Join(lines, "\n"))
}
}
return top + "\n" + middle + "\n" + bottom
}